Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 f043a1a392 #1032: spot colours defined in Lab keep their Lab values
A .ai or PDF whose spot colours are defined in Lab opened them wrong:
- the PDF part gives such an ink an ICC Lab profile as its alternate
  space ([/ICCBased profile], 3 components), which was read as RGB, so
  the Lab values were clamped into wildly wrong colours (PANTONE 249 C
  came in yellow). A profile whose Alternate is [/Lab ...], or whose
  header names the Lab data colour space, is now read as Lab;
- the editing data writes them as `c m y k L a b (name) tint 2 Xx`,
  type 2 being Lab, and only the CMYK equivalent was read. Type 2 now
  gives the Lab colour, in the swatches and on the art.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

PenJoin is a crosshair too under the preference.

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

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

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

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

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

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

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:35 +07:00
99 changed files with 4577 additions and 470 deletions

No files matched your search

+81 -1
View File
@@ -377,11 +377,91 @@ jobs:
path: dist/release/*
if-no-files-found: error
linux-riscv64:
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
# CLI is smoke-tested under QEMU (package.sh runs `vectorcraft-cli --version` through $EMULATOR).
runs-on: ubuntu-24.04
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v7
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: riscv64gc-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
with:
key: release-linux-riscv64
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Install cross-compilation dependencies
run: |
. /etc/os-release
sudo dpkg --add-architecture riscv64
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
fi
if [ -f /etc/apt/sources.list ]; then
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
fi
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
done | sudo tee /etc/apt/sources.list.d/riscv64.list
sudo apt-get update
# The riscv64 variants of the linux job's -dev packages.
sudo apt-get install -y --no-install-recommends \
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
libxi-dev:riscv64 libgl1-mesa-dev:riscv64 \
desktop-file-utils appstream
- name: Build and package (tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: "1"
CROSS_ARCH: riscv64
CROSS_TARGET: riscv64gc-unknown-linux-gnu
CROSS_COMPILE: riscv64-linux-gnu-
EMULATOR: qemu-riscv64
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
run: packaging/linux/package.sh --formats tar
- name: Check the package
run: |
tar -tzf dist/release/vectorcraft-*-linux-riscv64.tar.gz \
| grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
file target/riscv64gc-unknown-linux-gnu/release/vectorcraft | grep -F 'RISC-V'
- uses: actions/upload-artifact@v7
with:
name: release-linux-riscv64
path: dist/release/*.tar.gz
if-no-files-found: error
release:
name: Draft GitHub Release
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
# drafts a release.
needs: [version, macos, windows, linux, flatpak, freebsd, web]
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
Generated
+29 -29
View File
@@ -2167,9 +2167,9 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
[[package]]
name = "linesweeper"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c19728333c060c6569a53c9a0e56c4be0df52cb4e6e07a8fbe16084cecce769"
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
dependencies = [
"arrayvec",
"kurbo 0.13.1",
@@ -4320,7 +4320,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4350,7 +4350,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4361,7 +4361,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4373,7 +4373,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4389,7 +4389,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4410,7 +4410,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4422,7 +4422,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4439,7 +4439,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4454,7 +4454,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4489,7 +4489,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flate2",
"image",
@@ -4509,7 +4509,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flate2",
"proptest",
@@ -4523,7 +4523,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4535,7 +4535,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"log",
@@ -4554,7 +4554,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4568,7 +4568,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4580,7 +4580,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"aes",
"flate2",
@@ -4609,7 +4609,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde",
"serde_json",
@@ -4623,7 +4623,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flate2",
"gif",
@@ -4648,7 +4648,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flate2",
"image",
@@ -4672,7 +4672,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4689,7 +4689,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
@@ -4707,14 +4707,14 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"vectorcraft-geom",
]
[[package]]
name = "vectorcraft-tools"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4729,7 +4729,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4742,7 +4742,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"egui",
"egui_extras",
@@ -4773,7 +4773,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -5883,7 +5883,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde_json",
]
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.8.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -61,7 +61,7 @@ usvg = "0.48"
krilla = "0.8"
skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.4"
linesweeper = "0.5"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
+1 -1
View File
@@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). |
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). |
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
+7
View File
@@ -17,6 +17,9 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.lollinoo]
sync_github_name = true
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@@ -58,3 +61,7 @@ sync_github_name = false
real_name = "Fabio William Conceição"
display_name = "Messhias"
sync_github_name = false
[people.KattKnight]
display_name = "KattKnight (ayla.dsc)"
sync_github_name = false
+4 -1
View File
@@ -129,7 +129,7 @@ fn default_ratio(h: &reader::Header) -> (Unit, f64) {
/// The name of `$INSUNITS` code `code`.
fn units_name(code: i64) -> &'static str {
const NAMES: [&str; 22] = [
const NAMES: [&str; 25] = [
"Unitless",
"Inches",
"Feet",
@@ -152,6 +152,9 @@ fn units_name(code: i64) -> &'static str {
"Light years",
"Parsecs",
"US survey feet",
"US survey inches",
"US survey yards",
"US survey miles",
];
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
}
+6 -1
View File
@@ -66,6 +66,8 @@ pub(crate) struct Build<'d, 'a> {
/// The unit of `$INSUNITS` code `code` in points (0, unitless: none).
pub(crate) fn insunits_points(code: i64) -> Option<f64> {
let m = 72.0 / 0.0254;
// The US survey foot: exactly 1200/3937 m.
let survey_ft = m * 1200.0 / 3937.0;
Some(match code {
1 => 72.0,
2 => 864.0,
@@ -87,7 +89,10 @@ pub(crate) fn insunits_points(code: i64) -> Option<f64> {
18 => m * 1.495_978_707e11,
19 => m * 9.460_730_472_580_8e15,
20 => m * 3.085_677_581e16,
21 => m * 1200.0 / 3937.0,
21 => survey_ft,
22 => survey_ft / 12.0,
23 => survey_ft * 3.0,
24 => survey_ft * 5280.0,
_ => return None,
})
}
+14
View File
@@ -128,6 +128,20 @@ fn units_choose_the_default_scale() {
let um = info(&drawing(&[(9, "$INSUNITS"), (70, "13")], &[], &[], LINE)).unwrap();
assert_eq!((um.unit, um.units.as_str()), (Unit::Millimeters, "Microns"));
assert!((um.scale - 1000.0).abs() < 1e-6);
// #1011: US survey inches, yards and miles (from the survey foot, 1200/3937 m), not unitless.
let survey_ft = 72.0 / 0.0254 * 1200.0 / 3937.0;
for (code, name, pt) in [
("21", "US survey feet", survey_ft),
("22", "US survey inches", survey_ft / 12.0),
("23", "US survey yards", survey_ft * 3.0),
("24", "US survey miles", survey_ft * 5280.0),
] {
let i = info(&drawing(&[(9, "$INSUNITS"), (70, code)], &[], &[], LINE)).unwrap();
assert_eq!((i.unit, i.units.as_str()), (Unit::Millimeters, name));
assert!((i.scale * pt - 72.0 / 25.4).abs() < 1e-9, "{name}: 1 mm is {} units", i.scale);
}
let survey_in = open(&drawing(&[(9, "$INSUNITS"), (70, "22")], &[], &[], LINE), &ImportOptions::default()).document;
assert!((survey_in.artboards[0].rect.width() - 7200.0144000288).abs() < 1e-6, "{:?}", survey_in.artboards[0].rect);
let imperial = info(&drawing(&[(9, "$MEASUREMENT"), (70, "0")], &[], &[], LINE)).unwrap();
assert_eq!((imperial.unit, imperial.scale), (Unit::Inches, 1.0));
// A ratio: 1 cm = 10 units is 1:1 in millimetres again.
+73 -1
View File
@@ -402,12 +402,17 @@ pub struct Guide {
/// and moves, is copied and is deleted with it. None: a canvas guide, across the whole canvas.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artboard: Option<u32>,
/// The layer (or sublayer) it is on: it shows, hides and locks with that layer and is listed
/// in it in the Layers panel, and goes when the layer is deleted. None: on no layer (guides
/// from before guides had layers, and from files that don't say), always there.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer: Option<NodeId>,
}
impl Guide {
/// A canvas guide.
pub fn new(vertical: bool, pos: f64) -> Self {
Self { vertical, pos, artboard: None }
Self { vertical, pos, artboard: None, layer: None }
}
/// Moved by `d` (a vertical guide across, a horizontal one down).
@@ -990,6 +995,73 @@ impl Document {
let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect;
Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })
}
/// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has.
fn guide_layer(&self, g: &Guide) -> Option<NodeId> {
g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer))
}
/// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is
/// visible? A guide whose layer is gone shows as one on no layer.
pub fn guide_shown(&self, g: &Guide) -> bool {
self.guide_layer(g).is_none_or(|l| self.is_visible(l))
}
/// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked?
pub fn guide_editable(&self, g: &Guide) -> bool {
self.guide_layer(g).is_none_or(|l| self.is_editable(l))
}
/// Put the ruler guides on no layer this document has (from files saved before guides had
/// layers, or of layers merged into `layer`) on layer `layer`. Returns how many.
pub fn rehome_guides(&mut self, layer: NodeId) -> usize {
let lost: Vec<bool> = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect();
let mut n = 0;
for (g, lost) in self.guides.iter_mut().zip(lost) {
if lost {
g.layer = Some(layer);
n += 1;
}
}
n
}
/// Put the ruler guides on no layer (from files saved before guides had layers), or on one that
/// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many.
pub fn adopt_guides(&mut self) -> usize {
self.default_layer().map_or(0, |home| self.rehome_guides(home))
}
/// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in
/// step. Guides on no layer stay. Returns how many went.
pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize {
let gone: Vec<bool> = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect();
self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false))
}
/// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made
/// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies
/// the objects on it. Returns how many.
pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize {
let layers = |n: &Node| {
let mut out = vec![];
n.walk(&mut |c| {
if c.is_layer() {
out.push(c.id);
}
});
out
};
let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect();
let copies: Vec<Guide> = self
.guides
.iter()
.filter_map(|g| {
let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1;
Some(Guide { layer: Some(to), ..g.clone() })
})
.collect();
let n = copies.len();
self.guides.extend(copies);
n
}
/// The ruler guides on layer `layer` (not its sublayers'), by index.
pub fn guides_on(&self, layer: NodeId) -> impl Iterator<Item = (usize, &Guide)> + '_ {
self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer))
}
/// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)?
pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool {
let along = if g.vertical { p.y } else { p.x };
+52 -5
View File
@@ -29,8 +29,9 @@ pub struct Selection {
/// clears them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub slices: Vec<NodeId>,
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. They are
/// selected on their own: any change to the object selection clears them.
/// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new
/// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so
/// guides and objects can be selected together, in either order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guides: Vec<usize>,
}
@@ -66,7 +67,6 @@ impl Selection {
pub fn add(&mut self, id: NodeId) {
self.target = None;
self.slices.clear();
self.guides.clear();
if !self.objects.contains(&id) {
self.objects.push(id);
}
@@ -74,7 +74,6 @@ impl Selection {
pub fn remove(&mut self, id: NodeId) {
self.target = None;
self.slices.clear();
self.guides.clear();
self.objects.retain(|x| *x != id);
self.anchors.remove(&id);
if self.key == Some(id) {
@@ -99,6 +98,15 @@ impl Selection {
}
}
}
/// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a
/// Shift-click on a guide).
pub fn toggle_guide(&mut self, i: usize) {
if let Some(k) = self.guides.iter().position(|g| *g == i) {
self.guides.remove(k);
} else {
self.guides.push(i);
}
}
pub fn toggle(&mut self, id: NodeId) {
if self.contains(id) { self.remove(id) } else { self.add(id) }
}
@@ -117,7 +125,8 @@ impl Selection {
self.target = None;
}
self.slices.retain(|id| doc.is_slice(*id));
self.guides.retain(|i| *i < doc.guides.len());
// Guides on a hidden or locked layer can't stay selected.
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
}
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
/// (the art of its sublayers too), anything else is selected itself.
@@ -180,8 +189,16 @@ mod tests {
s.set_guides([2, 0, 2]);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0]));
assert!(s.has_objects_or_guides() && s.is_empty());
// Shift-adding an object keeps them; a new selection doesn't.
s.add(NodeId(1));
assert_eq!(s.guides, vec![2, 0]);
s.set([NodeId(1)]);
assert!(s.guides.is_empty());
// Toggled, a guide joins (or leaves) the selected objects.
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0]));
s.toggle_guide(0);
assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![]));
// Pruning drops guides the document no longer has.
let mut d = Document::new(100.0, 100.0);
d.guides.push(crate::Guide::new(true, 10.0));
@@ -190,6 +207,36 @@ mod tests {
assert_eq!(s.guides, vec![0]);
}
#[test]
fn guides_show_and_lock_with_their_layer() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) };
assert!(d.guide_shown(&g) && d.guide_editable(&g));
d.node_mut(layer).unwrap().locked = true;
assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable");
d.node_mut(layer).unwrap().visible = false;
assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden");
// On no layer, or a layer that's gone: always there.
assert!(d.guide_shown(&crate::Guide::new(false, 5.0)));
let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) };
assert!(d.guide_shown(&gone) && d.guide_editable(&gone));
d.guides.push(g);
assert_eq!(d.guides_on(layer).count(), 1);
// Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer.
d.node_mut(layer).unwrap().visible = true;
d.node_mut(layer).unwrap().locked = false;
d.guides.push(crate::Guide::new(false, 5.0));
d.guides.push(gone);
assert_eq!(d.adopt_guides(), 2);
assert!(d.guides.iter().all(|g| g.layer == Some(layer)));
assert_eq!(d.adopt_guides(), 0, "once");
// A guide saved before guides had layers reads as one on no layer.
let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap();
assert_eq!(old.layer, None);
assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written");
}
#[test]
fn selection_changes_clear_the_target() {
let mut s = Selection::default();
+61
View File
@@ -95,6 +95,11 @@ pub struct CharStyle {
/// their line line up with it.
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
pub char_align: CharAlign,
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
/// font's own proportional widths, OpenType `palt` (#966). Vertical type sets them as before
/// for now.
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
pub proportional_metrics: bool,
}
/// Where a character smaller than the largest on its line lines up with it: on the Roman
@@ -205,6 +210,7 @@ impl Default for CharStyle {
stroke_dash: None,
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
proportional_metrics: false,
small_caps: None,
}
}
@@ -954,7 +960,49 @@ pub struct TextObject {
pub cached_baselines: Vec<(Point, Point)>,
}
/// Conversion from stored character attributes to document-space type controls.
/// Point size follows the transformed em-height axis; horizontal scale is the ratio of
/// the two transformed em axes. Rotation, reflection and shear remain in the affine.
#[derive(Clone, Copy, Debug)]
pub struct TextStyleScale {
pub points: f64,
pub horizontal: f64,
}
impl TextObject {
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
/// be edited by compensating the stored attributes.
pub fn style_scale(&self) -> Option<TextStyleScale> {
let [a, b, c, d, e, f] = self.xf.as_coeffs();
if ![a, b, c, d, e, f].iter().all(|v| v.is_finite()) {
return None;
}
let (sx, sy) = (a.hypot(b), c.hypot(d));
let determinant = (a / sx) * (d / sy) - (b / sx) * (c / sy);
let horizontal = sx / sy;
if !sy.is_finite() || sy <= 0.0 || !horizontal.is_finite() || horizontal <= 0.0 || !determinant.is_finite() || determinant.abs() <= 1e-12 {
return None;
}
// Avoid a pure rotation turning otherwise identical fields into mixed values.
let unit = |v: f64| if (v - 1.0).abs() <= 1e-12 { 1.0 } else { v };
Some(TextStyleScale { points: unit(sy), horizontal: unit(horizontal) })
}
/// Character attributes as presented by controls (stored runs stay local for layout
/// and rich-text clipboard operations). Explicit vertical character scale stays separate.
pub fn effective_char_style(&self, style: &CharStyle) -> Option<CharStyle> {
let scale = self.style_scale()?;
let mut style = style.clone();
style.size *= scale.points;
style.leading = style.leading.map(|v| v * scale.points);
style.baseline_shift *= scale.points;
style.h_scale *= scale.horizontal;
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
return None;
}
Some(style)
}
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
vertical: false,
@@ -1190,6 +1238,19 @@ impl TextObject {
mod tests {
use super::*;
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
for matrix in [[0.0; 6], [1.0, 1.0, 2.0, 2.0, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 0.0, 0.0, 1.0, f64::NAN, 0.0]] {
t.xf = Affine::new(matrix);
assert!(t.effective_char_style(&t.first_style()).is_none());
}
t.xf = Affine::rotate(0.83);
assert_eq!(t.effective_char_style(&t.first_style()).unwrap().size, 12.0);
t.xf = Affine::scale(1e308);
assert!(t.effective_char_style(&t.first_style()).is_none(), "overflowing point size");
}
#[test]
fn point_text_basics() {
let t = TextObject::point(Point::new(10.0, 20.0), "Hello", CharStyle::default());
+71 -14
View File
@@ -160,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Guide",
[],
None,
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas)} → {index}",
"{vertical: bool, pos: pt (x for vertical, y for horizontal), artboard?: index (an artboard guide: it runs across that artboard only and moves, is copied and is deleted with it; default a canvas guide, across the whole canvas), layer?: id (the layer it is on, shown, hidden, locked and deleted with it; default the current layer)} → {index}",
has_doc,
guide_add
),
@@ -169,7 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Guides",
[],
None,
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's)}…] the ruler guides",
"{} → [{index, vertical, pos, selected, artboard?: index (an artboard guide's), layer?: id (the layer it is on), shown, editable (its layer shown and unlocked)}…] the ruler guides",
has_doc,
guide_list
),
@@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Guides",
[],
None,
"{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead → {selected: [index…]}",
"{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}",
guides_unlocked,
guide_select
),
@@ -200,6 +200,15 @@ pub fn specs() -> Vec<CommandSpec> {
guides_unlocked,
guide_move
),
cmd!(
"guide.setLayer",
"Move Guide to Layer",
[],
None,
"{index?, indexes?: [index…], layer: id} put ruler guide `index` (or guides `indexes`), else the selected guides, on layer `layer` (a layer or sublayer), as dragging their rows onto that layer's row in the Layers panel does → {count}",
guides_unlocked,
guide_set_layer
),
cmd!("file.closeAll", "Close All", ["File"], Some("Cmd+Alt+W"), "{} → {closed}", has_doc, close_all),
cmd!(
"file.documentColorMode",
@@ -507,8 +516,15 @@ fn guide_add(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
let st = s.doc()?;
let layer = match id_param(p, "layer") {
Some(l) if st.doc.node(l).is_some_and(Node::is_layer) => Some(l),
Some(_) => return Err(bad("guide.add", "layer must name a layer or sublayer")),
// Where new art goes (refused when no layer is shown and unlocked), or that group's layer.
None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }),
};
let i = s.edit("New Guide", |d, _| {
d.guides.push(Guide { artboard, ..Guide::new(vertical, pos) });
d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) });
Ok(d.guides.len() - 1)
})?;
Ok(json!({ "index": i }))
@@ -521,6 +537,11 @@ fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) {
r["artboard"] = json!(ab);
}
if let Some(l) = g.layer {
r["layer"] = json!(l.0);
}
r["shown"] = json!(st.doc.guide_shown(g));
r["editable"] = json!(st.doc.guide_editable(g));
r
};
Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect()))
@@ -535,19 +556,26 @@ fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
Ok(i)
}
/// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer.
fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result<usize> {
let i = guide_index(s, v, cmd)?;
let d = &s.doc()?.doc;
if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) {
return Err(EngineError::Other("the guide is on a hidden or locked layer".into()));
}
Ok(i)
}
fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?;
let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
if bool_or(p, "toggle", false) {
let mut now = s.doc()?.selection.guides.clone();
for i in picked {
if let Some(k) = now.iter().position(|g| *g == i) {
now.remove(k);
} else {
now.push(i);
// Added to (or taken from) what is selected, the art included.
s.select(|_, sel| {
for i in picked {
sel.toggle_guide(i);
}
}
s.select(|_, sel| sel.set_guides(now))?;
})?;
} else {
s.select(|_, sel| sel.set_guides(picked))?;
}
@@ -557,7 +585,7 @@ fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
/// Delete ruler guide `index`, else the selected guides, in one undo step → {count}.
pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
let gone: Vec<usize> = match p.get("index") {
Some(v) => vec![guide_index(s, Some(v), "guide.remove")?],
Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?],
None => s.doc()?.selection.guides.clone(),
};
if gone.is_empty() {
@@ -571,10 +599,39 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "count": gone.len() }))
}
/// Put ruler guide `index`, else the selected guides, on layer `layer`, in one undo step.
fn guide_set_layer(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "guide.setLayer";
let d = &s.doc()?.doc;
let layer =
id_param(p, "layer").filter(|l| d.node(*l).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "layer must name a layer or sublayer"))?;
// As objects dragged in the Layers panel: never onto a locked layer.
if super::layer::locked_within(d, layer) {
return Err(EngineError::Other("the layer or group is locked".into()));
}
let moving: Vec<usize> = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
(Some(v), _) => vec![editable_guide_index(s, Some(v), C)?],
(None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::<Result<Vec<_>>>()?,
(None, None) => s.doc()?.selection.guides.clone(),
};
if moving.is_empty() {
return Err(bad(C, "no guide selected (or give an index)"));
}
s.edit("Move Guide to Layer", |d, _| {
for i in &moving {
if let Some(g) = d.guides.get_mut(*i) {
g.layer = Some(layer);
}
}
Ok(())
})?;
Ok(json!({ "count": moving.len() }))
}
/// Put ruler guide `index` at `pos`, else move (or copy) the selected guides, in one undo step.
pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = p.get("index") {
let i = guide_index(s, Some(v), "guide.move")?;
let i = editable_guide_index(s, Some(v), "guide.move")?;
let pos = f64_req(p, "pos", "guide.move")?;
s.edit("Move Guide", |d, _| {
if let Some(g) = d.guides.get_mut(i) {
+6
View File
@@ -268,6 +268,8 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
return super::docmenu::guide_remove(s, &json!({}));
}
let st = s.doc()?;
// Guides selected with the art go with it (not when ids are given).
let with_guides = explicit.is_none();
let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc));
// Keep the selected ids themselves, without promoting compound members. Delete a selected
// ancestor just once; its selected descendants leave with it. Layers themselves are kept.
@@ -284,6 +286,10 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
d.remove(*id)?;
}
if with_guides {
let gone = sel.guides.clone();
d.retain_guides(sel, |i, _| !gone.contains(&i));
}
sel.clear();
Ok(())
})?;
@@ -108,6 +108,15 @@ showpage
let inner = outer.children().map(|c| (c.len(), c[1].children().map(Vec::len))).unwrap();
assert!(matches!(outer.kind, vectorcraft_doc::NodeKind::Group { clip: false, .. }), "{:?}", outer.kind);
assert_eq!(inner, (2, Some(2)));
// One that names the prolog defining its operators without including it (as other apps write
// the format) opens with the layers its program has (#1027).
let named = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%DocumentNeededResources: procset Adobe_IllustratorA_AI3 1.0 0\n%AI3_TemplateBox: 50 50 50 50\n%%EndComments\n\
%%BeginProlog\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\
%AI5_BeginLayer\n1 1 1 1 0 0 2 0 0 0 Lb\n(PLAN::Walls) Ln\n0 0 0 1 K\n0 i 1 w\n10 10 m\n90 90 L\nS\nLB\n%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n";
let r = open(&mut s, "cad.ai", named).unwrap();
assert_eq!(r["warnings"].as_array().map_or(0, Vec::len), 0, "{r}");
let d = s.doc().unwrap().doc.clone();
assert_eq!((d.layers.len(), d.layers[0].name.as_deref(), objects(&s)), (1, Some("PLAN::Walls"), 1));
// A .ait one opens as a new untitled document.
let r = open(&mut s, "legacy.ait", ps).unwrap();
assert!(r["title"].as_str().unwrap().starts_with("Untitled"), "{r}");
+9 -2
View File
@@ -221,7 +221,7 @@ pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, n: &Node) -> bool {
}
/// Whether `id` or a container around it is locked.
fn locked_within(d: &Document, id: NodeId) -> bool {
pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool {
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
}
@@ -462,6 +462,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
d.remove(*id)?;
}
// The guides on the deleted layers go with them.
d.drop_guides_of_deleted_layers(sel);
sel.prune(d);
Ok(())
})?;
@@ -487,6 +489,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
let mut c = d.reid(&n);
if n.is_layer() {
c.name = Some(format!("{} copy", n.display_name()));
d.copy_layer_guides(&n, &c);
}
out.push(d.insert(par, idx + 1, c)?);
}
@@ -679,7 +682,11 @@ fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
nodes.push(if copy {
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
d.reid(&n)
let c = d.reid(&n);
if n.is_layer() {
d.copy_layer_guides(&n, &c);
}
c
} else {
Arc::unwrap_or_clone(d.remove(*id)?)
});
+4
View File
@@ -263,6 +263,8 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
all.extend(taken);
all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id)));
*ch = all;
// The merged layers' guides go to it too.
d.rehome_guides(into);
Ok(())
}
@@ -311,6 +313,8 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
for h in &hidden {
d.remove(*h)?;
}
// Hidden layers are discarded with their guides.
d.drop_guides_of_deleted_layers(sel);
merge_into(d, into, &others)?;
sel.prune(d);
Ok(())
+166 -19
View File
@@ -251,17 +251,26 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
let areas = bool_or(p, "typeAreas", false);
let mut sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
sc.patterns = transform_patterns(s, p, &ids)?;
let ids = s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
for id in &targets {
if let Some(n) = d.node_mut(*id)
&& !(areas && resize_type_area(n, xf))
{
n.transform(xf, sc);
// A transform of ids that name no object, or an identity transform without a copy, moves
// nothing: the document and its undo history stay as they are, and Transform Again repeats it.
// With a copy, the reply lists the copies, and there are none.
let d = &s.doc()?.doc;
let none = ids.iter().all(|id| d.node(*id).is_none());
let ids = if none || (!copy && xf == Affine::IDENTITY) {
if copy { vec![] } else { ids }
} else {
s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
for id in &targets {
if let Some(n) = d.node_mut(*id)
&& !(areas && resize_type_area(n, xf))
{
n.transform(xf, sc);
}
}
}
Ok(targets)
})?;
Ok(targets)
})?
};
let st = s.doc_mut()?;
if st.interaction.is_none() {
st.last_transform = Some((xf, copy));
@@ -300,10 +309,19 @@ fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let dx = f64_or(p, "dx", 0.0) * k;
let dy = f64_or(p, "dy", 0.0) * k;
if !s.doc()?.selection.anchors.is_empty() {
// When the anchors move by 0, the document and its undo history stay as they are.
if dx == 0.0 && dy == 0.0 {
return ok();
}
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
}
if s.doc()?.selection.is_empty() {
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": bool_or(p, "copy", false) }));
let copy = bool_or(p, "copy", false);
// When guides move by 0 without a copy, the document and its undo history stay as they are.
if dx == 0.0 && dy == 0.0 && !copy {
return ok();
}
return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy }));
}
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p)
@@ -386,7 +404,9 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
Arrange::Backward => "Send Backward",
Arrange::Back => "Send to Back",
};
s.edit(label, |d, _| {
// When nothing changes order, the document and its undo history stay as they are.
super::overprint::edit_counted(s, label, |d| {
let mut moved = 0;
// Arrange within each parent independently (selections may span layers/groups).
let mut by_parent: Vec<(Option<vectorcraft_doc::NodeId>, Vec<vectorcraft_doc::NodeId>)> = vec![];
for id in &ids {
@@ -411,10 +431,11 @@ fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
};
if to != idx {
d.move_node(*id, par, to)?;
moved += 1;
}
}
}
Ok(())
Ok(moved)
})?;
ok()
}
@@ -424,6 +445,11 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
// Ids are reused after undo: the remembered current layer must still be a layer.
let st = s.doc()?;
let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?;
// When the selected objects are already the topmost in the current layer, in paint order, the
// document and its undo history stay as they are.
if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) {
return ok();
}
s.edit("Send to Current Layer", |d, _| {
for id in &ids {
d.move_node(*id, Some(layer), usize::MAX)?;
@@ -457,6 +483,11 @@ pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no group selected, the document, the selection and the undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) {
return ok();
}
s.edit("Ungroup", |d, sel| {
let mut new_sel = vec![];
for id in &ids {
@@ -492,6 +523,13 @@ fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
fn lock(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With every selected object already locked, the document and its undo history stay as they
// are, and the objects are deselected.
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) {
s.select(|_, sel| sel.clear())?;
return ok();
}
s.edit("Lock", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -516,6 +554,10 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| n.locked);
// With nothing locked, the document, the selection and the undo history stay as they are.
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Unlock All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -530,6 +572,13 @@ fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
fn hide(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With every selected object already hidden, the document and its undo history stay as they
// are, and the objects are deselected.
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) {
s.select(|_, sel| sel.clear())?;
return ok();
}
s.edit("Hide", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -544,6 +593,10 @@ fn hide(s: &mut Session, _: &Value) -> Result<Value> {
fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| !n.visible);
// With nothing hidden, the document, the selection and the undo history stay as they are.
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Show All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
@@ -602,6 +655,12 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no compound path selected, the document, the selection and the undo history stay as
// they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) {
return ok();
}
s.edit("Release Compound Path", |d, sel| {
let mut out = vec![];
for id in &ids {
@@ -678,6 +737,11 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
fn clip_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no clip group selected, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(Node::clips)) {
return ok();
}
s.edit("Release Clipping Mask", |d, _| {
for id in &ids {
release_clip(d, *id);
@@ -785,6 +849,27 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
),
None => None,
};
// When every object already has every value given, the document and its undo history stay as
// they are.
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let has_values = |n: &Node| {
str_param(p, "name").is_none_or(|v| n.name.as_deref() == Some(v).filter(|v| !v.is_empty()))
&& flag("visible").is_none_or(|v| n.visible == v)
&& flag("locked").is_none_or(|v| n.locked == v)
&& opacity.is_none_or(|v| n.opacity == v)
&& blend.is_none_or(|b| n.blend == b)
&& flag("isolate").is_none_or(|v| n.isolate == v)
&& knockout.is_none_or(|k| n.knockout == k)
&& flag("knockoutShape").is_none_or(|v| n.knockout_shape == v)
&& data
.iter()
.flatten()
.all(|(k, v)| n.attrs.as_ref().and_then(|a| a.data.iter().find(|(key, _)| key == k)).map(|(_, now)| now.as_str()) == v.as_deref())
};
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) {
return ok();
}
s.edit("Object Properties", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
@@ -864,6 +949,11 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
}
/// Is every move in `moves` a rounding error (1e-9 pt or less on both axes)?
fn moves_nothing(moves: &[(NodeId, Vec2)]) -> bool {
moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9)
}
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
/// selection.
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
@@ -915,6 +1005,10 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect()
};
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
}
s.edit("Align", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
@@ -965,6 +1059,10 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
})
.collect();
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
}
s.edit("Distribute", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
@@ -1008,6 +1106,10 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
// When nothing moves, the document and its undo history stay as they are.
if moves_nothing(&moves) {
return ok();
}
s.edit("Distribute Spacing", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
@@ -1057,12 +1159,23 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
// x, y place the reference point on the page.
let page = frame.to_doc() * rp;
// When every value given is the current one, the transform is the identity. Computing it can
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p);
}
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
}
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no live shape selected, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Path { live: Some(_), .. }))) {
return ok();
}
s.edit("Expand Shape", |d, _| {
for id in &ids {
if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
@@ -1138,6 +1251,45 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
// An ellipse's pie becomes `pie_of(pie)`. An end of 0 is a full turn (Illustrator shows a whole
// ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
let pie_of = |pie: (f64, f64)| {
let pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert { (pie.1.rem_euclid(360.0), end_of(pie.0)) } else { pie }
};
// When every value given is the current one, the document and its undo history stay as they
// are.
let st = s.doc()?;
let has_values = |id: &NodeId| {
let Some(NodeKind::Path { path, live, .. }) = st.doc.node(*id).map(|n| &n.kind) else { return true };
match live {
Some(l @ LiveShape::Polygon { sides: now, .. })
if !(sides.is_none_or(|n| n.clamp(3, 1000) == u64::from(*now))
&& (!equal || l.polygon_sides_equal())
&& polygon_angle.is_none_or(|a| l.polygon_angle() == Some(a))
&& side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius).is_none_or(|r| l.polygon_radius() == Some(r))) =>
{
return false;
}
Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false,
_ => {}
}
if radius.is_none() && kind.is_none() {
return true;
}
let Some(c) = LiveCorners::new(path, live.as_ref()) else { return true };
let picked = corners_of(*id).unwrap_or_else(|| c.picked(st.selection.anchors.get(id)));
// Setting corners folds a scale left in a rectangle's transform into its size, which
// changes it.
live.as_ref().is_none_or(|l| l.folded() == *l)
&& picked
.iter()
.all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd))
};
if ids.iter().all(has_values) {
return ok();
}
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -1163,12 +1315,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
&& (pie_start.is_some() || pie_end.is_some() || invert)
{
if let LiveShape::Ellipse { pie, .. } = l {
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert {
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
}
*pie = pie_of(*pie);
}
*path = l.to_path();
}
+29 -1
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -375,6 +375,34 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
/// remember the paint.
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
// Characters selected with the Type tool take the paint, not the whole type object (#1063).
if p.get("ids").is_none()
&& p.get("item").is_none()
&& let Some((id, a, b)) = super::textedit::editing_range(s)?
&& a != b
{
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let (xf, lb) = (t.xf, t.local_bounds());
vectorcraft_text::edit::style_range(&mut t.runs, a, b, |st| {
if fill {
st.fill = place_run_paint(&paint, p, xf, lb);
} else {
if !paint.is_none() {
run_stroke_weight(st);
}
st.stroke = place_run_paint(&paint, p, xf, vectorcraft_doc::appearance::stroke_paint_bounds(lb, st.stroke_width));
}
});
super::typecmd::refresh_bounds(t);
Ok(())
})?;
if bool_or(p, "focus", true) {
s.fill_active = fill;
}
s.remember_paint(&paint);
return ok();
}
// Resolve the requested targets before mutating defaults or the active proxy.
let item = item_target(s, p, cmd)?.of_kind(s, fill);
let selected = item.targets(s, p)?;
+12 -13
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -185,14 +185,7 @@ const MAX_PARA: f64 = 1296.0;
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setFormat";
let ids: Vec<NodeId> = {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
ids.into_iter().filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_)))).collect()
};
if ids.is_empty() {
return Err(bad(C, "no text objects selected"));
}
let ids = super::typecmd::text_targets(s, p, C)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let flag = |k: &str| p.get(k).and_then(Value::as_bool);
let kerning = match p.get("kerning") {
@@ -222,6 +215,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"direction",
"leadingModel",
"charAlign",
"proportionalMetrics",
"burasagari",
"composer",
];
@@ -285,15 +279,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?;
let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?;
super::typecmd::style_chars(t, range, |st| {
if let Some(k) = kerning {
st.kerning = k;
}
if let Some(v) = num("baselineShift") {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
if let Some(v) = baseline_shift {
st.baseline_shift = v;
}
if let Some(v) = num("hScale") {
st.h_scale = v.clamp(1.0, 10000.0);
if let Some(v) = h_scale {
st.h_scale = v;
}
if let Some(v) = num("vScale") {
st.v_scale = v.clamp(1.0, 10000.0);
@@ -319,6 +315,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = char_align {
st.char_align = v;
}
if let Some(v) = flag("proportionalMetrics") {
st.proportional_metrics = v;
}
});
let span = super::typecmd::para_span(range, t);
t.edit_paras(span, |para| {
+51 -16
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\"} style a character range (runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths, `palt`; horizontal type)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Get Text Range",
[],
None,
"{id, start?, end?} → {text, runs: [{text, style}], length}",
"{id, start?, end?, effective?: bool = false} → {text, runs: [{text, style}], length}; effective uses document-point size/leading/baseline shift and transformed horizontal scale; default returns stored local styles for rich-text copying",
has_doc,
get_range
),
@@ -174,7 +174,12 @@ fn get_range(s: &mut Session, p: &Value) -> Result<Value> {
let t = text_ref(s, id)?;
let len = edit::runs_len(&t.runs);
let (a, b) = range_of(p, len);
let runs = edit::slice_runs(&t.runs, a, b);
let mut runs = edit::slice_runs(&t.runs, a, b);
if p.get("effective").and_then(Value::as_bool).unwrap_or(false) {
for run in &mut runs {
run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?;
}
}
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
Ok(json!({"text": text, "runs": runs, "length": len}))
}
@@ -203,6 +208,7 @@ pub(crate) struct CharChange {
position: Option<vectorcraft_doc::CharPosition>,
small_caps: Option<Option<f64>>,
char_align: Option<vectorcraft_doc::CharAlign>,
proportional_metrics: Option<bool>,
}
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
@@ -286,6 +292,7 @@ impl CharChange {
position,
small_caps,
char_align: char_align_param(p, cmd)?,
proportional_metrics: flag("proportionalMetrics"),
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
@@ -314,6 +321,16 @@ impl CharChange {
&& self.position.is_none()
&& self.small_caps.is_none()
&& self.char_align.is_none()
&& self.proportional_metrics.is_none()
}
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
use super::typecmd::local_type_value;
self.size = self.size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, cmd)).transpose()?;
self.leading = self.leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, cmd)).transpose()).transpose()?;
self.baseline_shift = self.baseline_shift.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?;
Ok(())
}
pub(crate) fn apply(&self, st: &mut CharStyle) {
@@ -338,10 +355,10 @@ impl CharChange {
st.font_version = None;
}
if let Some(v) = self.size {
st.size = v.clamp(0.1, 1296.0);
st.size = v;
}
if let Some(v) = self.leading {
st.leading = v.map(|l| l.clamp(0.1, 5000.0));
st.leading = v;
}
if let Some(v) = self.tracking {
st.tracking = v.clamp(-1000.0, 10000.0);
@@ -350,10 +367,10 @@ impl CharChange {
st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0));
}
if let Some(v) = self.baseline_shift {
st.baseline_shift = v.clamp(-1296.0, 1296.0);
st.baseline_shift = v;
}
if let Some(v) = self.h_scale {
st.h_scale = v.clamp(1.0, 10000.0);
st.h_scale = v;
}
if let Some(v) = self.v_scale {
st.v_scale = v.clamp(1.0, 10000.0);
@@ -391,6 +408,9 @@ impl CharChange {
if let Some(v) = self.char_align {
st.char_align = v;
}
if let Some(v) = self.proportional_metrics {
st.proportional_metrics = v;
}
}
}
@@ -445,12 +465,13 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "missing `id`: give one, or select one type object")),
},
};
let change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
if change.is_empty() {
return Err(bad(C, "nothing to change"));
}
let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some());
let t = text_ref(s, id)?;
change.localize(&t, C)?;
let (a, b) = range_of(p, edit::runs_len(&t.runs));
let n = s.edit("Character", |d, _| {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
@@ -542,7 +563,7 @@ enum Step {
}
/// The Type tool's selected text while it edits (its typing session ends first: one undo step).
fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
pub(crate) fn editing_range(s: &mut Session) -> Result<Option<(NodeId, usize, usize)>> {
let o = s.tool_options();
let Some(id) = o.get("editing").and_then(Value::as_u64).map(NodeId) else { return Ok(None) };
if super::edit::typing_in_progress(s) {
@@ -605,13 +626,27 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for &(id, a, b) in &ranges {
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
edit::style_range(&mut t.runs, a, b, |st| match step {
Step::Size => st.size = (st.size + inc).clamp(0.1, 1296.0),
Step::Leading => st.leading = Some((st.effective_leading() + inc).clamp(0.1, 5000.0)),
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
Step::BaselineShift => st.baseline_shift = (st.baseline_shift + inc).clamp(-1296.0, 1296.0),
});
let points = if matches!(step, Step::Size | Step::Leading | Step::BaselineShift) {
t.style_scale().ok_or_else(|| bad(C, "text transform is collapsed or unrepresentable"))?.points
} else {
1.0
};
let mut runs = edit::slice_runs(&t.runs, a, b);
for run in &mut runs {
let st = &mut run.style;
match step {
Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?,
Step::Leading => {
st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?);
}
Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0),
Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)),
Step::BaselineShift => {
st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?;
}
}
}
edit::replace_range_styled(&mut t.runs, a, b, &runs);
refresh_bounds(t);
}
Ok(())
+48 -2
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
has_doc,
set_style
),
@@ -289,6 +289,22 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
Ok(t)
}
/// Clamp in the units shown to the user, then compensate the text's affine. Clamping
/// the local result would make small sizes impossible after enlarging an object.
pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> {
if !value.is_finite() {
return Err(bad(cmd, "type dimension must be finite"));
}
let scale = t.style_scale().ok_or_else(|| bad(cmd, "text transform is collapsed or unrepresentable"))?;
let local = value.clamp(limits.0, limits.1) / if horizontal { scale.horizontal } else { scale.points };
// Bound local dimensions too: an extreme inverse scale can produce a finite size
// whose leading, glyph advances or layout bounds overflow before the edit's geometry check.
if !local.is_finite() || local.abs() > crate::MAX_COORD || (value != 0.0 && local == 0.0) {
return Err(bad(cmd, "type dimension cannot be represented with this text transform"));
}
Ok(local)
}
/// Optional `start`/`end` byte offsets of a text command: the character range it styles and the
/// paragraphs it touches. Without either, the command applies to all the text.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -415,6 +431,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Character", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?;
let leading = leading.map(|v| v.map(|v| local_type_value(t, v, (0.1, 5000.0), false, C)).transpose()).transpose()?;
let before = protect.then(|| t.runs.clone());
style_chars(t, range, |st| {
if let Some(f) = &font {
@@ -428,7 +446,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
st.font_version = None;
}
if let Some(v) = size {
st.size = v.clamp(0.1, 1296.0);
st.size = v;
}
if let Some(l) = leading {
st.leading = l;
@@ -841,6 +859,34 @@ mod area_tests {
}
}
/// Proportional Metrics (#966): a character attribute, saved only when on.
#[test]
fn proportional_metrics_is_a_character_attribute_saved_only_when_on() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽"})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
assert!(!runs(&s)[0].style.proportional_metrics);
assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("proportionalMetrics").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"proportionalMetrics": true})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.proportional_metrics));
assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["proportionalMetrics"], true);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!runs(&s)[0].style.proportional_metrics);
// Characters selected with the Type tool: only the range takes it (雅 is 3 bytes).
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 6, "proportionalMetrics": true})).unwrap();
let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.proportional_metrics)).collect();
assert_eq!(on, [("雅".to_string(), false), ("楽".to_string(), true)]);
// Styles written without it (documents from before it) read as off.
let old = serde_json::to_value(&runs(&s)[0].style).unwrap();
assert!(old.get("proportionalMetrics").is_none());
assert!(!serde_json::from_value::<vectorcraft_doc::CharStyle>(old).unwrap().proportional_metrics);
}
#[test]
fn new_type_is_composed_with_line_end_half_width_punctuation() {
use vectorcraft_doc::Mojikumi;
+12
View File
@@ -1041,6 +1041,8 @@ impl Session {
/// Add a document and make it active.
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
Self::refresh_text_bounds(&mut doc);
// Guides from files saved before guides had layers go on a layer.
doc.adopt_guides();
self.reset_tool_for_doc_switch();
let mut st = DocState::new(doc, path);
st.history.limit = self.prefs.history_states as usize;
@@ -1056,6 +1058,7 @@ impl Session {
return false;
}
Self::refresh_text_bounds(&mut doc);
doc.adopt_guides();
if self.active == Some(index) {
// Pending tool work (typing, a drag) belongs to the content being thrown away.
self.reset_tool_for_doc_switch();
@@ -1321,6 +1324,11 @@ impl Session {
{
let st = self.doc_mut()?;
let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) };
// Undoing the previous preview counts as a change, so views redraw even when the command
// below returns without an edit.
if !Arc::ptr_eq(&st.doc, &it.doc) || st.selection != it.selection {
st.revision += 1;
}
st.doc = it.doc.clone();
st.selection = it.selection.clone();
}
@@ -1629,6 +1637,8 @@ mod tests_recolor;
#[cfg(test)]
mod tests_recovery;
#[cfg(test)]
mod tests_reflecthit;
#[cfg(test)]
mod tests_registration;
#[cfg(test)]
mod tests_save;
@@ -1681,6 +1691,8 @@ mod tests_transparencygrid;
#[cfg(test)]
mod tests_typearea;
#[cfg(test)]
mod tests_typescale;
#[cfg(test)]
mod tests_units;
#[cfg(test)]
mod tests_variables;
+420
View File
@@ -330,6 +330,426 @@ fn selection_tool_drag_is_one_undo_step() {
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0);
}
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
fn history(s: &Session) -> (usize, usize, bool) {
let st = s.doc().unwrap();
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
}
/// Select `ids` with a move of them left to redo, and mark the document saved.
fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.doc_mut().unwrap().mark_saved();
}
/// An Align that moves nothing (one object on the artboard's left edge, a pair already aligned, a
/// pair that an earlier Align left a rounding error apart) records no undo step: the step to redo
/// stays, the document stays unmodified, and the journal, which the Actions panel records from,
/// still has the command.
#[test]
fn an_align_that_moves_nothing_records_no_undo_step() {
let mut s = session();
let x0 = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
let left = json!({"horizontal": "left"});
let a = rect(&mut s, 0.0, 50.0, 40.0, 40.0);
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
// Object › Align sends no `to`, and the Align panel aligns one object to the artboard.
for p in [left.clone(), json!({"horizontal": "left", "to": "artboard"})] {
s.execute("object.align", &p).unwrap();
assert_eq!(history(&s), before, "one object and no key: {p}");
assert_eq!(s.journal.last().unwrap(), &("object.align".to_string(), p));
}
let b = rect(&mut s, 0.0, 200.0, 80.0, 20.0);
select_with_a_step_to_redo(&mut s, &[a, b]);
let before = history(&s);
s.execute("object.align", &left).unwrap();
assert_eq!(history(&s), before, "a pair already aligned");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((x0(&s, a), x0(&s, b)), (5.0, 5.0), "Redo brings back the move");
// An Align that moves something is one undo step, and it clears Redo.
let c = rect(&mut s, 120.0, 300.0, 20.0, 20.0);
select_with_a_step_to_redo(&mut s, &[a, c]);
let undo = history(&s).0;
s.execute("object.align", &left).unwrap();
assert_eq!(history(&s), (undo + 1, 0, true));
assert_eq!(x0(&s, c), 5.0);
// Aligning a rectangle at 51.8 to one at 10.7 leaves it a rounding error off 10.7, and a
// second Align records no step.
let d = rect(&mut s, 10.7, 400.0, 20.0, 20.0);
let e = rect(&mut s, 51.8, 450.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [d.0, e.0]})).unwrap();
s.execute("object.align", &left).unwrap();
assert!(x0(&s, e) != 10.7 && (x0(&s, e) - 10.7).abs() < 1e-12, "{}", x0(&s, e));
s.doc_mut().unwrap().mark_saved();
let before = history(&s);
s.execute("object.align", &left).unwrap();
assert_eq!(history(&s), before, "a pair the first Align aligned");
}
/// Distribute and Distribute Spacing over objects already evenly spaced move nothing and record no
/// undo step either.
#[test]
fn a_distribute_that_moves_nothing_records_no_undo_step() {
let mut s = session();
// The rectangles are 20 pt wide and 30 pt apart.
let ids = [0.0, 50.0, 100.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
select_with_a_step_to_redo(&mut s, &ids);
let before = history(&s);
let calls = [
("object.distribute", json!({"horizontal": "left"})),
("object.distribute", json!({"horizontal": "center"})),
("object.distribute", json!({"horizontal": "right"})),
("object.distributeSpacing", json!({"axis": "horizontal"})),
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 30})),
];
for (id, p) in calls {
s.execute(id, &p).unwrap();
assert_eq!(history(&s), before, "{id} {p}");
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
}
// Distribute Spacing keeps the key object still and spaces the others from it.
s.execute("select.key", &json!({"id": ids[1].0})).unwrap();
assert_eq!(s.doc().unwrap().selection.key, Some(ids[1]));
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 30})).unwrap();
assert_eq!(history(&s), before, "spaced 30 pt from the key object");
// After the middle one moves 10 pt right, Distribute moves it back in one undo step.
s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap();
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
let undo = history(&s).0;
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
assert_eq!(history(&s).0, undo + 1);
assert_eq!(s.doc().unwrap().doc.node(ids[1]).unwrap().geometric_bounds().unwrap().x0, 50.0);
// For rectangles 30.1 pt apart, the gap Distribute Spacing computes moves the third one by a
// rounding error.
let ids = [0.1, 50.2, 100.3].map(|x| rect(&mut s, x, 100.0, 20.0, 20.0));
select_with_a_step_to_redo(&mut s, &ids);
let before = history(&s);
s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap();
assert_eq!(history(&s), before, "30.1 pt apart");
// Distributing these three leaves the middle one a rounding error off halfway between the
// others, and a second Distribute records no step.
let ids = [40.045, 433.482, 1935.587].map(|x| rect(&mut s, x, 200.0, 20.0, 20.0));
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = history(&s);
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
assert_eq!(history(&s), before, "three objects the first Distribute spaced");
}
/// Move by 0 pt, Rotate and Shear by 0°, Scale to 100%, an identity Transform, Set Bounds with the
/// current position and size, a Nudge of 0 (of objects, anchors or ruler guides), a Transform with
/// no object to transform and a Selection tool drag that ends where it started record no undo
/// step. Transform Again then repeats the identity, and a Nudge of 0 of a live rectangle's anchor
/// leaves the rectangle a live shape.
#[test]
fn a_transform_that_moves_nothing_records_no_undo_step() {
let mut s = session();
// The Nudges of a guide and of anchors below use this ruler guide and this plain path.
s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap();
let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 300}, {"x": 400, "y": 300}]})).unwrap();
let path = NodeId(made["id"].as_u64().unwrap());
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
let calls = [
("object.move", json!({"dx": 0, "dy": 0})),
("object.rotate", json!({"angle": 0})),
("object.scale", json!({"sx": 100})),
("object.shear", json!({"angle": 0})),
("object.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})),
("object.nudge", json!({"dx": 0, "dy": 0})),
("object.setBounds", json!({})),
("object.transformAgain", json!({})),
];
for (id, p) in calls {
assert_eq!(s.execute(id, &p).unwrap(), json!({"ids": [a.0]}), "{id} {p}");
assert_eq!(history(&s), before, "{id} {p}");
assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p));
}
assert_eq!(s.doc().unwrap().last_transform, Some((Affine::IDENTITY, false)));
// A Nudge of 0 with an anchor of the rectangle direct-selected keeps it a live shape.
let is_live = |s: &Session| matches!(s.doc().unwrap().doc.node(a).unwrap().kind, NodeKind::Path { live: Some(_), .. });
assert!(is_live(&s), "a rectangle is a live shape");
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
s.execute("object.nudge", &json!({"dx": 0, "dy": 0})).unwrap();
assert!(is_live(&s), "a Nudge of 0 keeps it a live shape");
assert_eq!(history(&s), before, "an anchor of the live rectangle, then object.nudge");
// A Nudge of 0 with anchors direct-selected or with only a ruler guide selected, and a
// Transform with nothing selected or with ids that name no object, with or without a copy,
// record no undo step either.
let calls = [
("select.anchors", json!({"id": path.0, "anchors": [[0, 0]]}), "object.nudge", json!({"dx": 0, "dy": 0})),
("guide.select", json!({"indexes": [0]}), "object.nudge", json!({"dx": 0, "dy": 0})),
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})),
("select.set", json!({"ids": []}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true})),
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999]})),
("select.set", json!({"ids": [a.0]}), "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true})),
];
for (select, sp, id, p) in calls {
s.execute(select, &sp).unwrap();
s.execute(id, &p).unwrap();
assert_eq!(history(&s), before, "{select} {sp}, then {id} {p}");
}
// A copy of no object lists no copies and keeps the selection.
let p = json!({"matrix": [1, 0, 0, 1, 5, 0], "ids": [999999], "copy": true});
assert_eq!(s.execute("object.transform", &p).unwrap(), json!({"ids": []}));
assert_eq!(s.doc().unwrap().selection.objects, [a]);
// A Selection tool drag that ends where it started records no undo step either. Back at the
// start, the revision advances, so views redraw the object where it was.
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let x0 = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0;
let revision = |s: &Session| s.doc().unwrap().revision;
s.pointer(&PointerEvent::new(PointerKind::Down, 30.0, 35.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 60.0, 35.0), v).unwrap();
assert_ne!(x0(&s), 10.0, "the preview moves it");
let moved = revision(&s);
s.pointer(&PointerEvent::new(PointerKind::Drag, 30.0, 35.0), v).unwrap();
assert_eq!(x0(&s), 10.0);
assert!(revision(&s) > moved, "views redraw it back at the start");
s.pointer(&PointerEvent::new(PointerKind::Up, 30.0, 35.0), v).unwrap();
assert_eq!(history(&s), before, "a drag back to the start");
assert_eq!(s.journal.last().unwrap().0, "object.transform");
// With a copy, a Move by 0 pt duplicates the object in one undo step.
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len();
let n = count(&s);
s.execute("object.move", &json!({"dx": 0, "dy": 0, "copy": true})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
assert_eq!(count(&s), n + 1);
}
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
/// Current Layer on objects already on top of the current layer.
#[test]
fn an_arrange_that_changes_no_order_records_no_undo_step() {
let mut s = session();
let ids = [0.0, 30.0, 60.0].map(|x| rect(&mut s, x, 0.0, 20.0, 20.0));
let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
let calls = [
(vec![ids[2]], "object.arrange.bringToFront"),
(vec![ids[2]], "object.arrange.bringForward"),
(vec![ids[0]], "object.arrange.sendToBack"),
(vec![ids[0]], "object.arrange.sendBackward"),
(vec![ids[2]], "object.arrange.sendToCurrentLayer"),
(vec![ids[1], ids[2]], "object.arrange.bringToFront"),
(vec![ids[1], ids[2]], "object.arrange.bringForward"),
(vec![ids[0], ids[1]], "object.arrange.sendToBack"),
(vec![ids[0], ids[1]], "object.arrange.sendBackward"),
(vec![ids[1], ids[2]], "object.arrange.sendToCurrentLayer"),
];
for (sel, c) in calls {
select_with_a_step_to_redo(&mut s, &sel);
let before = history(&s);
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), before, "{c} {sel:?}");
assert_eq!(order(&s), ids, "{c} {sel:?}");
}
// Bring to Front on the back object is one undo step, and it clears Redo.
select_with_a_step_to_redo(&mut s, &[ids[0]]);
let undo = history(&s).0;
s.execute("object.arrange.bringToFront", &json!({})).unwrap();
assert_eq!(history(&s), (undo + 1, 0, true));
assert_eq!(order(&s), [ids[1], ids[2], ids[0]]);
}
/// Ungroup, Release Compound Path and Release Clipping Mask with none of their objects selected,
/// and Expand Shape with no live shape selected, record no undo step and keep the selection and its
/// key object.
#[test]
fn ungroup_release_and_expand_shape_with_nothing_to_act_on_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
// Expand Shape makes the two rectangles plain paths in one undo step.
let undo = history(&s).0;
s.execute("object.expandShape", &json!({})).unwrap();
assert_eq!(history(&s).0, undo + 1);
select_with_a_step_to_redo(&mut s, &[a, b]);
s.execute("select.key", &json!({"id": a.0})).unwrap();
let (before, selection) = (history(&s), s.doc().unwrap().selection.clone());
assert_eq!(selection.key, Some(a));
for c in ["object.ungroup", "object.compoundPath.release", "object.clippingMask.release", "object.expandShape"] {
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), before, "{c}");
assert_eq!(s.doc().unwrap().selection, selection, "{c}");
assert_eq!(s.journal.last().unwrap().0, c);
}
// Ungroup with a group selected is one undo step.
s.execute("object.group", &json!({})).unwrap();
let undo = history(&s).0;
s.execute("object.ungroup", &json!({})).unwrap();
assert_eq!(history(&s).0, undo + 1);
}
/// Unlock All with nothing locked and Show All with nothing hidden record no undo step and keep the
/// selection. Lock with every selected object already locked and Hide with every one already
/// hidden record no undo step and deselect them.
#[test]
fn lock_hide_unlock_all_and_show_all_with_nothing_to_do_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
for c in ["object.unlockAll", "object.showAll"] {
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"count": 0}), "{c}");
assert_eq!(history(&s), before, "{c}");
assert_eq!(s.doc().unwrap().selection.objects, [a], "{c}");
}
// With the object locked or hidden, each is one undo step that selects it.
for (off, on) in [("object.lock", "object.unlockAll"), ("object.hide", "object.showAll")] {
s.execute(off, &json!({})).unwrap();
let undo = history(&s).0;
assert_eq!(s.execute(on, &json!({})).unwrap(), json!({"count": 1}), "{on}");
assert_eq!(history(&s).0, undo + 1, "{on}");
assert_eq!(s.doc().unwrap().selection.objects, [a], "{on}");
}
// Object Properties locks (then hides) a new rectangle `b`, and `select.set` selects it.
// Lock (then Hide) deselects it and records no undo step. With `a` selected too, it is one
// undo step.
for (c, props) in [("object.lock", json!({"locked": true})), ("object.hide", json!({"visible": false}))] {
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
s.execute("object.setProps", &props).unwrap();
select_with_a_step_to_redo(&mut s, &[a]);
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
let before = history(&s);
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), before, "{c}");
assert!(s.doc().unwrap().selection.objects.is_empty(), "{c}");
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true), "{c} with a");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// Set Bounds with the current position and size records no undo step on a rotated object, with
/// and without Use Preview Bounds, and on an unrotated object with Use Preview Bounds. In these
/// cases computing the transform can leave a rounding error in it.
#[test]
fn set_bounds_with_the_current_position_and_size_records_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
let c = rect(&mut s, 0.1, 0.05, 10.1, 6.06);
s.execute("stroke.set", &json!({"weight": 0.3})).unwrap();
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
for (id, preview) in [(a, false), (a, true), (c, true)] {
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("prefs.set", &json!({"key": "usePreviewBounds", "value": preview})).unwrap();
// The Transform panel's values: X and Y place the reference point, W and H are the sides.
let b = s.transform_box(&[id]).unwrap();
let (rp, w, h) = (b.reference_point(4), b.rect.width(), b.rect.height());
for p in [json!({}), json!({"x": rp.x, "y": rp.y, "width": w, "height": h}), json!({"width": w, "proportional": true})] {
assert_eq!(s.execute("object.setBounds", &p).unwrap(), json!({"ids": [id.0]}), "{p}");
assert_eq!(history(&s), before, "{id:?}, Use Preview Bounds {preview}: {p}");
assert_eq!(s.journal.last().unwrap().0, "object.setBounds");
}
}
// A new width is one undo step, and it clears Redo.
s.execute("object.setBounds", &json!({"width": 50})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
/// Object Properties with the values the objects already have records no undo step: every
/// property, data already set or already absent, and no object. A value that differs on one of
/// the objects is one undo step, and an id that names no object is still an error.
#[test]
fn object_properties_already_set_record_no_undo_step() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 0.0, 20.0, 20.0);
let set = json!({"ids": [a.0], "name": "A", "opacity": 50, "blend": "Multiply", "knockout": "on", "data": {"pivot": "1,2"}});
s.execute("object.setProps", &set).unwrap();
select_with_a_step_to_redo(&mut s, &[a, b]);
let before = history(&s);
let defaults = json!({
"id": b.0, "name": "", "visible": true, "locked": false, "opacity": 100, "blend": "Normal", "isolate": false,
"knockout": "neutral", "knockoutShape": false, "data": {"pivot": null}
});
let calls =
[set, defaults, json!({"id": a.0, "data": {"data-pivot": "1,2", "other": null}}), json!({"visible": true}), json!({}), json!({"ids": []})];
for p in calls {
s.execute("object.setProps", &p).unwrap();
assert_eq!(history(&s), before, "{p}");
assert_eq!(s.journal.last().unwrap(), &("object.setProps".to_string(), p));
}
// `a` already has 50% opacity, and `b` does not.
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err());
}
/// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle,
/// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the
/// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that
/// differs is one undo step. Setting the radius of a rectangle that kept a scale in its transform
/// is one undo step too, because it folds the scale into the rectangle's size.
#[test]
fn live_shape_properties_already_set_record_no_undo_step() {
let mut s = session();
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
let r = rect(&mut s, 0.0, 0.0, 100.0, 60.0);
let polygon = id(s.execute("shape.polygon", &json!({"cx": 300, "cy": 100, "radius": 50, "sides": 6})).unwrap());
let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 80, "height": 40})).unwrap());
let star = id(s.execute("shape.star", &json!({"cx": 300, "cy": 300, "radius1": 60, "radius2": 30})).unwrap());
// The rectangle's corners get 8 pt radii, its top-right corner gets 12 pt, and the polygon gets
// 30 pt sides.
for p in [json!({"id": r.0, "radius": 8}), json!({"id": r.0, "corners": [1], "radius": 12}), json!({"id": polygon.0, "sideLength": 30})] {
s.execute("object.setLiveShape", &p).unwrap();
}
let NodeKind::Path { live: Some(l), .. } = &s.doc().unwrap().doc.node(polygon).unwrap().kind else { panic!("not a live polygon") };
let (angle, radius) = (l.polygon_angle().unwrap(), l.polygon_radius().unwrap());
select_with_a_step_to_redo(&mut s, &[r, polygon, ellipse, star]);
let before = history(&s);
let calls = [
json!({"id": polygon.0, "sides": 6, "polygonAngle": angle, "polygonRadius": radius, "makeSidesEqual": true}),
json!({"id": polygon.0, "sideLength": 30, "kind": "round"}),
json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 360}),
json!({"id": ellipse.0, "invertPie": true}),
json!({"id": r.0, "corners": [0, 2, 3], "radius": 8, "kind": "round"}),
json!({"items": [{"id": r.0, "corners": [1]}], "radius": 12}),
json!({"id": star.0, "radius": 0}),
];
for p in calls {
s.execute("object.setLiveShape", &p).unwrap();
assert_eq!(history(&s), before, "{p}");
assert_eq!(s.journal.last().unwrap(), &("object.setLiveShape".to_string(), p));
}
// With an anchor of the top-left corner direct-selected, the radius applies to that corner.
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap();
s.execute("object.setLiveShape", &json!({"radius": 8})).unwrap();
assert_eq!(history(&s), before, "the Direct-Selected corner");
for p in [json!({"id": polygon.0, "sides": 7}), json!({"id": ellipse.0, "pieEnd": 90}), json!({"radius": 9})] {
let undo = history(&s).0;
s.execute("object.setLiveShape", &p).unwrap();
assert_eq!(history(&s).0, undo + 1, "{p}");
}
// The rectangle is now 50 × 60 pt with 10 pt corners, stretched to 100 × 60 pt by its
// transform, as files from before #291 saved it. Setting the radius its corners show makes the
// corners circles.
{
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") };
let xf = Affine::scale_non_uniform(2.0, 1.0);
*live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf };
*path = live.to_path();
}
let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0);
let undo = history(&s).0;
s.execute("object.setLiveShape", &json!({"id": r.0, "radius": shown})).unwrap();
assert_eq!(history(&s).0, undo + 1, "a stretched rectangle");
}
#[test]
fn rectangle_tool_draws() {
let mut s = session();
+4
View File
@@ -870,6 +870,10 @@ fn the_pen_continues_any_open_path_and_joins_another() {
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin);
assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path");
// #1009: Use Precise Cursors makes the join pointer a crosshair too.
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap();
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::Crosshair);
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1);
let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::<Vec<_>>();
assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]);
+155 -5
View File
@@ -785,7 +785,11 @@ fn ruler_guides_select_move_and_delete() {
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([0]));
assert!(selected(&s).is_empty());
assert_eq!(s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap()["selected"], json!([0, 1]));
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1], json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true}));
let layer = s.doc().unwrap().current_layer().unwrap().0;
assert_eq!(
s.execute("guide.list", &json!({})).unwrap()[1],
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": true, "layer": layer, "shown": true, "editable": true})
);
assert!(s.execute("guide.select", &json!({"indexes": [7]})).is_err());
// A move takes each selected guide along its own axis, in one undo step.
let n = undo_len(&s);
@@ -823,6 +827,40 @@ fn ruler_guides_select_move_and_delete() {
assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..))));
}
/// Shift selects ruler guides and art together, in either order; Delete takes both in one step.
#[test]
fn guides_and_art_are_shift_selected_together() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 40.0, 20.0);
let b = rect(&mut s, 70.0, 50.0, 60.0, 30.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
// Art first, then a Shift-click on a guide: the art stays selected.
sel(&mut s, &[a]);
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
assert_eq!((selected(&s), selected_guides(&s)), (vec![a], vec![0]));
// A guide first, then Shift-added art: the guide stays selected.
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
assert_eq!((selected(&s), selected_guides(&s)), (vec![b], vec![1]));
// A new selection drops them.
sel(&mut s, &[a]);
assert!(selected_guides(&s).is_empty());
// Delete takes the art and the guides, in one step; undo brings both back.
s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap();
let n = undo_len(&s);
s.execute("edit.clear", &json!({})).unwrap();
assert_eq!((guides(&s), undo_len(&s)), (vec![(true, 200.0)], n + 1));
assert!(s.doc().unwrap().doc.node(a).is_none() && s.doc().unwrap().doc.node(b).is_some());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 2);
// Ids given: only those go, the guides stay.
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
s.execute("select.add", &json!({"ids": [b.0]})).unwrap();
s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap();
assert_eq!(guides(&s).len(), 2);
}
/// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved,
/// copied and deleted with it; canvas guides stay put.
#[test]
@@ -832,11 +870,12 @@ fn artboard_guides_go_with_their_artboard() {
s.execute("guide.add", &json!({"vertical": true, "pos": 1000, "artboard": 1})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 100})).unwrap();
let list = |s: &mut Session| s.execute("guide.list", &json!({})).unwrap();
let layer = s.doc().unwrap().current_layer().unwrap().0;
assert_eq!(
list(&mut s),
json!([
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1},
{"index": 1, "vertical": false, "pos": 100.0, "selected": false},
{"index": 0, "vertical": true, "pos": 1000.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true},
{"index": 1, "vertical": false, "pos": 100.0, "selected": false, "layer": layer, "shown": true, "editable": true},
])
);
let spans = |s: &Session| {
@@ -875,7 +914,10 @@ fn artboard_guides_go_with_their_artboard() {
let n = undo_len(&s);
s.execute("artboard.delete", &json!({"index": 0})).unwrap();
assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1]));
assert_eq!(list(&mut s)[0], json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true}));
assert_eq!(
list(&mut s)[0],
json!({"index": 0, "vertical": false, "pos": 100.0, "selected": true, "layer": layer, "shown": true, "editable": true})
);
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((guides(&s).len(), selected_guides(&s)), (4, vec![1, 2]));
@@ -917,12 +959,120 @@ fn guides_dragged_out_of_a_ruler_snap_and_take_the_artboard_tool_s_artboard() {
s.set_tool_option("active", &json!(1));
s.ruler_guide(false, &ev(PointerKind::Drag, 1000.0, 50.0), true, v).unwrap();
s.ruler_guide(false, &ev(PointerKind::Up, 1000.0, 50.0), true, v).unwrap();
let layer = s.doc().unwrap().current_layer().unwrap().0;
assert_eq!(
s.execute("guide.list", &json!({})).unwrap()[1],
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1})
json!({"index": 1, "vertical": false, "pos": 50.0, "selected": false, "artboard": 1, "layer": layer, "shown": true, "editable": true})
);
}
/// Guides saved before guides had layers are put on a layer when the document opens.
#[test]
fn guides_from_older_files_are_put_on_a_layer() {
let mut s = Session::new();
let mut d = vectorcraft_doc::Document::new(400.0, 300.0);
d.guides.push(vectorcraft_doc::Guide::new(true, 50.0));
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
s.add_document(d, None);
let st = s.doc().unwrap();
let top = st.doc.default_layer();
assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides);
}
/// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it,
/// and moved to another layer.
#[test]
fn ruler_guides_live_on_layers() {
let mut s = session();
let first = s.doc().unwrap().current_layer().unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer");
let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap());
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current");
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err());
// Hidden or locked with its layer.
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
assert_eq!(s.execute("guide.list", &json!({})).unwrap()[1]["shown"], false);
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
let row = s.execute("guide.list", &json!({})).unwrap()[1].clone();
assert_eq!((row["shown"].clone(), row["editable"].clone()), (json!(true), json!(false)));
s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap();
// Moved to the first layer, then back.
s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first));
s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap();
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err());
// Several at once, as dragging their rows onto a layer's row does.
s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap();
assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first)));
s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap();
assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err());
// Deleting a layer deletes its guides; undo brings them back.
s.execute("layer.delete", &json!({"ids": [second.0]})).unwrap();
assert_eq!(guides(&s), [(true, 200.0)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 2);
}
/// Guides on a hidden or locked layer leave the selection and can't be moved, deleted or moved to
/// another layer; new guides skip a locked current layer as new art does; no guide goes onto a
/// locked layer.
#[test]
fn guides_on_hidden_or_locked_layers_are_left_alone() {
let mut s = session();
let first = s.doc().unwrap().current_layer().unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
s.execute("guide.select", &json!({"indexes": [0, 1]})).unwrap();
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": true})).unwrap();
assert_eq!(selected_guides(&s), [1], "the locked layer's guide is deselected");
assert_eq!(s.execute("guide.select", &json!({"indexes": [0]})).unwrap()["selected"], json!([]), "and can't be selected");
assert!(s.execute("guide.move", &json!({"index": 0, "pos": 50})).is_err());
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
assert!(s.execute("guide.setLayer", &json!({"index": 0, "layer": second.0})).is_err());
assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).is_err(), "not onto a locked layer");
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
assert!(selected_guides(&s).is_empty(), "the hidden layer's guide is deselected");
// Both layers hidden or locked: a new guide is refused, as new art is.
assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 10})).is_err());
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": true, "locked": true})).unwrap();
s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap();
// The current layer locked: the new guide goes on the top shown, unlocked layer.
s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first));
assert_eq!(guides(&s).len(), 3);
}
/// Guides follow their layers through Duplicate, Merge Selected and Flatten Artwork, in one undo
/// step each.
#[test]
fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() {
let mut s = session();
let first = s.doc().unwrap().current_layer().unwrap();
s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap();
let second = id_of(&s.execute("layer.new", &json!({})).unwrap());
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::<Vec<_>>();
// Duplicate Layer copies its guides onto the copy.
let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap();
assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 2);
// Merged into the first layer, the second layer's guide goes with its art.
s.execute("layer.merge", &json!({"ids": [second.0, first.0]})).unwrap();
assert_eq!(on(&s, first), [(true, 200.0), (false, 300.0)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((on(&s, first), on(&s, second)), (vec![(true, 200.0)], vec![(false, 300.0)]));
// Flattened into the first layer, a hidden layer is discarded with its guides.
s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap();
s.execute("layer.flatten", &json!({"id": first.0})).unwrap();
assert_eq!(guides(&s), [(true, 200.0)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s).len(), 2);
}
/// #451: art moved with the Selection tool lands flush on the artboard's edges and centre (Smart
/// Guides).
#[test]
+153
View File
@@ -0,0 +1,153 @@
//! A reflected object is still clicked by its fill, not only by its outline.
use serde_json::{Value, json};
use vectorcraft_geom::Point;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// A click with the Selection tool at (x, y): what it selects.
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), v).unwrap();
}
s.doc().unwrap().selection.objects.clone()
}
#[test]
fn a_reflected_shape_is_clicked_by_its_fill() {
const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
let makers: [(&str, Value); 5] = [
("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
("shape.ellipse", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 5})),
("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})),
("path.create", json!({"d": HEART})),
];
for (cmd, params) in makers {
for axis in ["horizontal", "vertical"] {
let mut s = session();
let id = id_of(&s.execute(cmd, &params).unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
let c = b.center();
assert_eq!(click(&mut s, c.x, c.y), vec![id], "{cmd} reflected {axis}: a click in its fill at {c:?} (bounds {b:?})");
}
}
}
/// Hearts as SVG files bring them: a plain path, a compound path, inside a group, under a
/// transform. Reflected with the button and with a bounding-box handle dragged across.
#[test]
fn a_reflected_imported_heart_is_clicked_by_its_fill() {
const D: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
let files = [
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><path fill="#e2384d" d="{D}"/></svg>"##),
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><path fill="#e2384d" fill-rule="evenodd" d="{D} M190 200h20v20h-20z"/></svg>"##
),
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><g><g fill="#e2384d"><path d="{D}"/></g></g></svg>"##),
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><g transform="translate(10 0) scale(1.2)"><path fill="#e2384d" transform="matrix(1 0 0 1 -20 -20)" d="{D}"/></g></svg>"##
),
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 200 200"><path style="fill:#e2384d;stroke:none" d="{D}" transform="scale(0.5)"/></svg>"##
),
];
for (i, svg) in files.iter().enumerate() {
for how in ["horizontal", "vertical", "handle"] {
let mut s = Session::new();
s.add_document(vectorcraft_svg::import(svg).unwrap(), None);
s.execute("select.all", &json!({})).unwrap();
let ids = s.doc().unwrap().selection.objects.clone();
assert!(!ids.is_empty(), "file {i}: nothing imported");
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
if how == "handle" {
// Drag the top handle down past the bottom: a top-to-bottom flip.
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let (from, to) = (Point::new(b.center().x, b.y0), Point::new(b.center().x, b.y1 + b.height()));
for (kind, p) in
[(PointerKind::Down, from), (PointerKind::Drag, Point::new(from.x, b.center().y)), (PointerKind::Drag, to), (PointerKind::Up, to)]
{
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
}
} else {
s.execute("object.reflect", &json!({"axis": how})).unwrap();
}
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
// Inside the heart away from the compound's square hole: a third of the way down.
let c = Point::new(b.center().x, if how == "horizontal" || how == "handle" { b.y1 - b.height() * 0.3 } else { b.y0 + b.height() * 0.3 });
let picked = click(&mut s, c.x, c.y);
assert_eq!(picked, ids, "file {i} flipped by {how}: a click in its fill at {c:?} (bounds {b:?})");
}
}
}
/// Still selected after the flip, a drag from inside the fill moves it.
#[test]
fn a_reflected_selected_shape_drags_by_its_fill() {
const HEART: &str = "M200 290C190 280 110 230 110 170C110 140 130 120 155 120C175 120 192 132 200 148C208 132 225 120 245 120C270 120 290 140 290 170C290 230 210 280 200 290Z";
let makers: [(&str, Value); 4] = [
("shape.rectangle", json!({"x": 150, "y": 150, "width": 100, "height": 100})),
("shape.polygon", json!({"cx": 200, "cy": 200, "radius": 60, "sides": 3})),
("shape.star", json!({"cx": 200, "cy": 200, "radius1": 70, "radius2": 40, "points": 5})),
("path.create", json!({"d": HEART})),
];
for (cmd, params) in makers {
for axis in ["horizontal", "vertical"] {
let mut s = session();
let id = id_of(&s.execute(cmd, &params).unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
let b = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
let c = b.center();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
for (kind, x) in
[(PointerKind::Down, c.x), (PointerKind::Drag, c.x + 20.0), (PointerKind::Drag, c.x + 40.0), (PointerKind::Up, c.x + 40.0)]
{
s.pointer(&PointerEvent::new(kind, x, c.y).with_mods(Mods::default()), v).unwrap();
}
let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
assert!((after.x0 - b.x0 - 40.0).abs() < 1e-6, "{cmd} reflected {axis}: a drag from {c:?} left it at {after:?} (was {b:?})");
}
}
}
/// An open path (an anchor deleted from a closed one) still fills as if closed, and its fill is
/// clicked on both sides of that closing line, reflected or not.
#[test]
fn an_open_filled_path_is_clicked_by_its_fill_either_way_round() {
// The sliver of a strawberry, open, its closing line down its left side.
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path fill="#c22a3f" d="M86.311 69.612C100.544 78.261 110.506 88.64 110.506 100.748C110.506 111.127 100.544 118.046 86.311 118.046C93.214 102.045 91.407 102.403 91.407 97.61"/></svg>"##;
for axis in [None, Some("vertical"), Some("horizontal")] {
let mut s = Session::new();
s.add_document(vectorcraft_svg::import(svg).unwrap(), None);
s.execute("select.all", &json!({})).unwrap();
let ids = s.doc().unwrap().selection.objects.clone();
if let Some(axis) = axis {
s.execute("object.reflect", &json!({"axis": axis})).unwrap();
}
let b = s.doc().unwrap().doc.bounds_of(&ids, false).unwrap();
s.execute("select.set", &json!({"ids": []})).unwrap();
// The middle of the sliver's widest part, well inside it.
let x = if axis == Some("vertical") { b.x0 + b.width() * 0.4 } else { b.x0 + b.width() * 0.6 };
assert_eq!(click(&mut s, x, b.center().y), ids, "reflected {axis:?}: a click in the fill (bounds {b:?})");
}
}
+32
View File
@@ -563,3 +563,35 @@ fn automatic_alignment_updates_during_edits_and_can_be_overridden() {
legacy["para"]["justify"] = json!("Left");
assert_eq!(serde_json::from_value::<TextObject>(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left);
}
/// #1063: a fill (or stroke) chosen while the Type tool has characters selected colours just
/// those characters, as one undo step; the rest of the type keeps its colour.
#[test]
fn a_fill_chosen_with_characters_selected_colours_only_them() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 50.0, 300.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("Sample Text", v).unwrap();
let before = obj(&s, id).runs[0].style.fill.clone();
s.set_tool_option("select", &json!({"start": 0, "end": 6}));
s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap();
let t = obj(&s, id);
assert_eq!(t.runs.len(), 2);
assert_eq!(t.runs[0].text, "Sample");
assert_eq!(t.runs[0].style.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 160, 0)));
assert_eq!(t.runs[1].text, " Text");
assert_eq!(t.runs[1].style.fill, before);
// A stroke goes on the selected characters only, with a weight to show.
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
let t = obj(&s, id);
assert!(t.runs[0].style.stroke_width > 0.0 && !t.runs[0].style.stroke.is_none());
assert!(t.runs[1].style.stroke.is_none());
// One undo step each.
let labels = undo_labels(&s);
assert_eq!(&labels[labels.len() - 2..], ["Fill Color", "Stroke Color"]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(obj(&s, id).runs.iter().all(|r| r.style.fill == before));
}
+229
View File
@@ -0,0 +1,229 @@
//! #1034: Character sizes and horizontal scales account for the object's affine transform.
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind, TextObject};
use vectorcraft_geom::Point;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn create(s: &mut Session, size: f64) -> NodeId {
NodeId(s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Axis label", "size": size})).unwrap()["id"].as_u64().unwrap())
}
fn text(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn em(t: &TextObject, run: usize) -> (f64, f64) {
let st = &t.runs[run].style;
let origin = t.xf * Point::ZERO;
((t.xf * Point::new(st.size * st.h_scale / 100.0, 0.0) - origin).hypot(), (t.xf * Point::new(0.0, st.size * st.v_scale / 100.0) - origin).hypot())
}
fn near(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}");
}
#[test]
fn scaled_font_size_setter_sets_document_points_and_undoes() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
s.execute("object.scale", &json!({"sx": 150})).unwrap();
let before = text(&s, id);
near(em(&before, 0).1, 36.0);
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
let after = text(&s, id);
near(em(&after, 0).1, 10.0);
assert_eq!(after.xf, before.xf, "font editing preserves placement and rotation");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s, id), before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(text(&s, id), after);
}
#[test]
fn group_font_size_setter_handles_different_accumulated_scales() {
let mut s = session();
let a = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
let b = create(&mut s, 18.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("object.scale", &json!({"sx": 200})).unwrap();
near(em(&text(&s, a), 0).1, 48.0);
near(em(&text(&s, b), 0).1, 36.0);
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
near(em(&text(&s, a), 0).1, 10.0);
near(em(&text(&s, b), 0).1, 10.0);
}
#[test]
fn scaled_range_size_and_keyboard_steps_use_document_points() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 4, "size": 10})).unwrap();
near(em(&text(&s, id), 0).1, 10.0);
near(em(&text(&s, id), 1).1, 24.0);
s.prefs.type_size_increment = 2.0;
s.execute("type.step", &json!({"attribute": "size", "id": id.0, "start": 0, "end": 4})).unwrap();
near(em(&text(&s, id), 0).1, 12.0);
near(em(&text(&s, id), 1).1, 24.0);
}
#[test]
fn horizontal_scale_reset_removes_object_stretch_including_groups() {
for grouped in [false, true] {
let mut s = session();
let id = create(&mut s, 12.0);
if grouped {
s.execute("object.group", &json!({})).unwrap();
}
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
let before = text(&s, id);
s.execute("text.setFormat", &json!({"hScale": 100, "vScale": 100})).unwrap();
let after = text(&s, id);
near(em(&after, 0).0, 12.0);
near(em(&after, 0).1, 12.0);
assert_eq!(after.xf, before.xf);
}
}
#[test]
fn rotated_reflected_nonuniform_range_edits_preserve_the_affine() {
let mut s = session();
let id = create(&mut s, 12.0);
// A quarter turn, reflected in the baseline axis: horizontal ×2, vertical ×3.
s.execute("object.transform", &json!({"matrix": [0, -2, -3, 0, 0, 0]})).unwrap();
let before = text(&s, id);
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100, "vScale": 100})).unwrap();
let after = text(&s, id);
near(em(&after, 0).0, 10.0);
near(em(&after, 0).1, 10.0);
assert_eq!(after.xf, before.xf);
// The native document representation retains both the compensation and the affine.
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
let reopened = vectorcraft_format::load(&bytes).unwrap();
let NodeKind::Text(t) = &reopened.node(id).unwrap().kind else { panic!("not text") };
near(em(t, 0).0, 10.0);
near(em(t, 0).1, 10.0);
}
#[test]
fn effective_range_query_leaves_raw_runs_available_for_clipboard() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 300, "sy": 200})).unwrap();
let query = |s: &mut Session, effective| -> Value {
s.execute("text.getRange", &json!({"id": id.0, "effective": effective})).unwrap()["runs"][0]["style"].clone()
};
let effective = query(&mut s, true);
assert_eq!(effective["size"], 24.0);
assert_eq!(effective["h_scale"], 150.0);
let raw = query(&mut s, false);
assert_eq!(raw["size"], 12.0);
assert_eq!(raw["h_scale"], 100.0);
}
#[test]
fn scaled_point_units_and_small_sizes_are_converted_before_local_storage() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
s.execute("text.setStyle", &json!({"size": 0.1, "leading": 14})).unwrap();
let t = text(&s, id);
near(em(&t, 0).1, 0.1);
near(t.runs[0].style.leading.unwrap() * 2.0, 14.0);
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 12, "baselineShift": 4, "leading": 16})).unwrap();
let t = text(&s, id);
near(t.runs[0].style.baseline_shift * 2.0, 4.0);
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
}
#[test]
fn singular_text_size_edit_returns_an_error_and_rolls_back_the_group() {
let mut s = session();
let a = create(&mut s, 12.0);
let b = create(&mut s, 18.0);
s.execute("object.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
assert!(s.execute("text.setStyle", &json!({"size": 10})).is_err());
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
assert!(s.execute("text.setRangeStyle", &json!({"id": b.0, "size": 10})).is_err());
assert!(s.execute("text.setFormat", &json!({"id": b.0, "hScale": 100})).is_err());
// Non-dimensional edits remain possible, including on imported collapsed text.
s.execute("text.setStyle", &json!({"fill": "#123456"})).unwrap();
}
#[test]
fn sheared_text_keeps_its_affine_and_accepts_exact_em_dimensions() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.transform", &json!({"matrix": [2, 0, 1, 2, 0, 0]})).unwrap();
let before = text(&s, id);
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 10, "hScale": 100})).unwrap();
let after = text(&s, id);
near(em(&after, 0).0, 10.0);
near(em(&after, 0).1, 10.0);
assert_eq!(after.xf, before.xf);
}
#[test]
fn area_frame_resize_reflows_at_same_size_and_object_scaling_reports_scaled_size() {
let mut s = session();
let id = NodeId(
s.execute("text.create", &json!({"x": 40, "y": 60, "text": "Area label", "size": 12, "area": {"width": 120, "height": 80}})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("object.transform", &json!({"matrix": [2, 0, 0, 2, 0, 0], "typeAreas": true})).unwrap();
near(em(&text(&s, id), 0).1, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
near(em(&text(&s, id), 0).1, 24.0);
s.execute("text.setStyle", &json!({"size": 10})).unwrap();
near(em(&text(&s, id), 0).1, 10.0);
}
#[test]
fn point_based_keyboard_increments_and_format_fields_follow_document_units() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.scale", &json!({"sx": 200})).unwrap();
s.execute("text.setStyle", &json!({"leading": 14})).unwrap();
s.execute("text.setFormat", &json!({"baselineShift": 4})).unwrap();
s.prefs.type_size_increment = 2.0;
s.prefs.baseline_shift_increment = 3.0;
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
s.execute("type.step", &json!({"attribute": "baselineShift"})).unwrap();
let t = text(&s, id);
near(t.runs[0].style.leading.unwrap() * 2.0, 16.0);
near(t.runs[0].style.baseline_shift * 2.0, 7.0);
}
#[test]
fn tiny_finite_transform_rejects_unsafe_compensation_without_history_changes() {
let mut s = session();
let id = create(&mut s, 12.0);
s.execute("object.transform", &json!({"matrix": [1e-306, 0, 0, 1e-306, 0, 0]})).unwrap();
let before = text(&s, id);
let history = s.doc().unwrap().history.undo.len();
let error = s.execute("text.setStyle", &json!({"size": 160})).unwrap_err();
assert!(matches!(error, EngineError::BadParams { .. }), "reject before attempting unsafe layout: {error}");
assert_eq!(text(&s, id), before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
+55 -4
View File
@@ -1280,11 +1280,17 @@ proptest! {
/// Hostile PostScript programs: operators in any order with any operands, opened and placed;
/// as files in the legacy Illustrator format too (the creator line is what turns on its
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips.
/// documented `u` … `U` groups), the groups unbalanced, deep and among clips; in a layer, which
/// the editing data reader reads from the program too.
#[test]
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>()) {
fn eps_hostile_programs_never_panic(tokens in prop::collection::vec(arb_ps_token(), 0..60), illustrator in any::<bool>(), layers in any::<bool>()) {
let head = if illustrator { "%%Creator: Adobe Illustrator(R) 8.0\n%%EndComments\n/u {} def /U {} def" } else { "%%EndComments" };
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{}\nshowpage\n", tokens.join(" "));
let (open, close) = if layers {
("/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def\n%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(L) Ln\n", "\nLB\n%AI5_EndLayer--")
} else {
("", "")
};
let ps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 200\n{head}\n{open}{}{close}\nshowpage\n", tokens.join(" "));
survive("hostile PostScript", || vectorcraft_engine::cmd::fileio::load("x.eps", ps.as_bytes()).ok().map(|l| l.doc))?;
let r = catch_quiet(|| {
let mut s = rich_session();
@@ -1702,6 +1708,9 @@ fn ascii85(data: &[u8]) -> String {
out
}
/// A pattern's definition in editing data: its name and tile, its art on `%_` lines, its end.
const PATTERN: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n%_0 A\n%_0 Ae\n%_u\n%_0 0 m\n%_0 6 L\n%_6 6 L\n%_6 0 L\n%_n\n%_0 0 0 1 K\n%_1 J 1 w\n%_3 3 m\n%_3 3 L\n%_S\n%_U\n%_9 () XW\nE\n%AI3_EndPattern\n";
/// The editing data of a file with every sort of object the layers reader knows, and some it doesn't.
fn editing_text() -> String {
let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4);
@@ -1724,13 +1733,16 @@ fn editing_text() -> String {
"0 Xw".into(),
"1 0 0 0 1 0 Bg".into(),
"0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(),
"0 0 0 1 (Black global) 0.4 0 Xk".into(),
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p".into(),
square(60),
]
.join("\n");
let layer = |name: &str, visible: u8, body: &str| {
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
};
format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n",
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{PATTERN}{}{}%%Trailer\n",
layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))),
layer("Two", 0, &square(80))
)
@@ -1872,6 +1884,35 @@ proptest! {
}
}
/// A `.ai` in PostScript form as other apps write it: the procsets that define its operators named,
/// not included, so its program can't be run and its layers are read from it.
const NAMED_PROLOG_AI: &str = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 200 100\n%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n\
%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\nAdobe_packedarray /initialize get exec\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n\
%AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(A::B) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M [2 1]0 d\n0 D\n10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\n\
u\n0.5 g\n20 20 m\n40 20 L\n40 40 L\nf\nU\n%AI5_BeginLayer\n0 1 1 1 0 0 3 0 0 0 Lb\n(Sub) Ln\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\
%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n";
/// The fixture, as it is, opens with its layer and sublayer.
#[test]
fn the_named_prolog_fixture_opens_with_its_layers() {
let l = vectorcraft_engine::cmd::fileio::load("x.ai", NAMED_PROLOG_AI.as_bytes()).unwrap();
let names: Vec<_> = l.doc.layers.iter().map(|l| l.name.clone()).collect();
assert_eq!(names, [Some("A::B".to_string())], "{:?}", l.warnings);
assert_eq!(l.doc.layers[0].children().unwrap().len(), 3);
}
proptest! {
#![proptest_config(config())]
/// A `.ai` in PostScript form whose prolog is named, not included, damaged: read from its
/// layers, or refused.
#[test]
fn ps_form_ai_never_panics(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '%', 'u', 'U', 'L', 'b', 'B', 'i', 'A', 'K'])), 0..12)) {
let text = mutate_text(NAMED_PROLOG_AI, cut, &edits);
survive("mutated PostScript-form .ai", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?;
}
}
/// The operators, operands and section comments of hostile editing data.
fn arb_ai_token() -> impl Strategy<Value = String> {
prop_oneof![
@@ -1980,6 +2021,16 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"/Binary : /ASCII85Decode ,",
"~>",
"p",
"P",
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p",
"(Dots) 0 0 1 1 0 0 0 0 0 [0 0 0 0 1e308 0] P",
"Xk",
"XK",
"0 0 0 1 (Global) 0.4 0 Xk",
"1 0.5 0 (Global) 0 1 XK",
"E",
"\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n",
"\n%AI3_EndPattern\n",
"To",
"frobnicate",
"\n%AI5_BeginLayer\n",
+10
View File
@@ -81,6 +81,16 @@ impl<'a> Lexer<'a> {
rest.get(..n).unwrap_or_default()
}
/// Where the next token is looked for.
pub fn offset(&self) -> usize {
self.pos
}
/// The data from `from` to `to` (empty where that isn't in it).
pub fn slice(&self, from: usize, to: usize) -> &'a [u8] {
self.src.get(from..to).unwrap_or_default()
}
/// Skip past the next `needle` (the end of data that isn't tokens) → what was skipped.
pub fn skip_past(&mut self, needle: &[u8]) -> &'a [u8] {
let rest = self.rest();
+5
View File
@@ -52,6 +52,11 @@ pub fn eps_data(ps: &[u8]) -> Option<Result<Vec<u8>, String>> {
Some(eps_section(section))
}
/// Does the EPS `ps` carry editing data ([`eps_data`] isn't `None`)?
pub fn has_eps_data(ps: &[u8]) -> bool {
find(ps, EPS_BEGIN).is_some()
}
/// The editing data between the EPS markers: header comments, then the data (encoded or not).
fn eps_section(section: &[u8]) -> Result<Vec<u8>, String> {
let mut header = Vec::new();
+41 -7
View File
@@ -2,8 +2,10 @@
//!
//! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
//! values its equivalent), `Xx`/`XX` a named colour of either model
//! (`c m y k [r g b] (name) tint type`). Lower case paints fills, upper case strokes.
//! values its equivalent), `Xx`/`XX` a named colour of any model
//! (`c m y k [r g b | L a b] (name) tint type`, type 0 CMYK, 1 RGB, 2 Lab), `Xk`/`XK` a global
//! process colour (its operands as `Xx`'s: a global swatch that isn't an ink of its own). Lower
//! case paints fills, upper case strokes.
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
@@ -28,6 +30,8 @@ pub(super) struct Named {
/// The full colour.
pub color: Color,
pub tint: f32,
/// An ink of its own (a spot colour), not a global process colour.
pub spot: bool,
}
/// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named
@@ -48,12 +52,13 @@ pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
}
"x" => named(vals, Some(false)),
"xx" => named(vals, None),
"xk" => named(vals, None).map(|(p, n)| (p, n.map(|n| Named { spot: false, ..n }))),
_ => None,
}
}
/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces
/// the model.
/// `c m y k (name) tint x` or `c m y k [r g b | L a b] (name) tint type Xx` (type 1: RGB, 2: Lab);
/// `rgb` forces RGB or CMYK.
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
let name = vals.get(at)?.text()?;
@@ -62,9 +67,14 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
let unit = |v: f32| v.clamp(0.0, 1.0);
let tint = 1.0 - unit(*after.first()?);
let n = before.len();
let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) {
let kind = after.get(1).copied();
let color = if rgb.unwrap_or(kind == Some(1.0)) {
let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None };
Color::rgb(unit(*r), unit(*g), unit(*b))
} else if rgb.is_none() && kind == Some(2.0) {
// Type 2: a colour defined in Lab, its L a b values after the CMYK equivalent (#1032).
let [l, a, b] = before.get(n.checked_sub(3)?..)? else { return None };
Color::lab(l.clamp(0.0, 100.0), a.clamp(-128.0, 127.0), b.clamp(-128.0, 127.0))
} else {
// The CMYK values come first (an RGB equivalent may follow them).
let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? };
@@ -72,7 +82,7 @@ fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))
};
let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint };
Some((paint, Some(Named { name, color, tint })))
Some((paint, Some(Named { name, color, tint, spot: true })))
}
/// The colour of a gradient stop's colour values and style.
@@ -162,7 +172,7 @@ pub(super) struct Instance {
hilight: Vec2,
}
fn affine(n: &[f64]) -> Option<Affine> {
pub(super) fn affine(n: &[f64]) -> Option<Affine> {
let [a, b, c, d, e, f] = n else { return None };
let m = Affine::new([*a, *b, *c, *d, *e, *f]);
(m.as_coeffs().iter().all(|v| v.is_finite())).then_some(m)
@@ -247,6 +257,30 @@ mod tests {
assert!((named.tint - 0.6).abs() < 1e-6);
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
assert!(color_op("k", &nums(&[1.0])).is_none());
// A global process colour: CMYK (type 0) or RGB (type 1), not an ink.
let mut v = nums(&[0.0, 0.0, 0.0, 1.0]);
v.extend([V::Str(b"K100".to_vec()), V::Num(0.4), V::Num(0.0)]);
let (p, named) = color_op("XK", &v).unwrap();
let named = named.unwrap();
assert_eq!((named.name.as_str(), named.color, named.spot), ("K100", Color::cmyk(0.0, 0.0, 0.0, 1.0), false));
assert!((named.tint - 0.6).abs() < 1e-6);
assert!(
matches!(p, Paint::Solid { swatch: Some(s), tint, color: Color::Cmyk { k, .. } } if s == "K100" && (tint - 0.6).abs() < 1e-6 && (k - 0.6).abs() < 1e-6)
);
let mut v = nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.5, 0.0]);
v.extend([V::Str(b"Orange".to_vec()), V::Num(0.0), V::Num(1.0)]);
let (p, _) = color_op("Xk", &v).unwrap();
assert_eq!(p.color(), Some(Color::rgb(1.0, 0.5, 0.0)));
// A spot colour defined in Lab (type 2): its L a b values, not its CMYK equivalent (#1032).
let mut v = nums(&[0.52, 0.98, 0.28, 0.1, 32.9412, 45.0, -18.0]);
v.extend([V::Str(b"Plum Ink".to_vec()), V::Num(0.0), V::Num(2.0)]);
let (p, named) = color_op("Xx", &v).unwrap();
let named = named.unwrap();
assert_eq!((named.color, named.spot, named.tint), (Color::lab(32.9412, 45.0, -18.0), true, 1.0));
assert_eq!(p.color(), Some(Color::lab(32.9412, 45.0, -18.0)));
v[8] = V::Num(0.25);
let (_, named) = color_op("XX", &v).unwrap();
assert_eq!(named.map(|n| (n.color, n.tint)), Some((Color::lab(32.9412, 45.0, -18.0), 0.75)));
}
#[test]
+179 -25
View File
@@ -2,23 +2,31 @@
//!
//! - Art space has y up; the first artboard's top left corner is the document's origin.
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed … colorIndex r g b …
//! dimPercent … Lb`, `(name) Ln`, its art, `LB`.
//! dimPercent … Lb` (ten operands in the legacy format, without the one before the colour
//! index), `(name) Ln`, its art, `LB`.
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
//! with `W` clips); a group's transparency and name follow its end.
//! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case
//! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`.
//! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`.
//! - Names: an `/ArtDictionary` after an object, its `AI10_ArtUID` the name's XML id.
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`.
//! - Artboards: the `ArtboardArray` of the `/Document` dictionary, else `%AI3_Cropmarks`, else
//! the art size around the template box's centre, else (a file in the legacy format another
//! app wrote) the bounding box.
//! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …`
//! and the samples after `XI`.
//! - Art with an appearance the format can't write as plain art (several fills or strokes,
//! effects, brushes) comes first as that look, drawn (a group), then the object itself on `%_`
//! lines and `1 (style) XW`: the drawn look is kept, named after the object.
//! - Patterns: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury` (the tile, in the pattern's
//! space, y up), the tile's art (on `%_` lines in newer files), `E`, `%AI3_EndPattern`; each is
//! read by a reader of its own and becomes a pattern swatch. A fill or stroke with one:
//! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix
//! taking the pattern's space to art space (the other operands are what it is made of).
//!
//! Sections the reader doesn't need (patterns, brushes, symbols, styles, swatches, filters) are
//! skipped; type, symbols, pattern fills, placed files, legacy type and operators it doesn't know
//! are errors.
//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped;
//! type, symbols, fills with a pattern it can't read, placed files, legacy type and operators it
//! doesn't know are errors.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::rc::Rc;
@@ -30,7 +38,7 @@ use vectorcraft_color::swatch::Swatch;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::{
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, Knockout, LayerColor, LineCap, LineJoin,
Node, NodeId, NodeKind, StrokeLayer,
Node, NodeId, NodeKind, PatternDef, StrokeLayer,
};
use vectorcraft_geom::{FillRule, PathData};
@@ -51,6 +59,10 @@ const MAX_POINTS: usize = 4_000_000;
const MAX_PIXELS: u64 = 1 << 25;
/// Largest side of the document and its artboards (points).
const MAX_SIDE: f64 = 1e6;
/// Art space (y up) → a pattern's space in the document's terms (y down).
const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
/// Most patterns read (others can't fill art).
const MAX_PATTERNS: usize = 4096;
/// What the editing data holds: the document of its art, and where its art space is.
#[derive(Debug)]
@@ -197,6 +209,16 @@ struct Frame {
clips: Vec<usize>,
}
/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands
/// before its first operator.
#[derive(Debug, Default)]
struct PatternHead {
taken: bool,
name: String,
/// The tile in the pattern's space (y up).
tile: Option<Rect>,
}
/// An image's operands, from `XN` and before `XI`.
#[derive(Debug, Default)]
struct Raster {
@@ -260,6 +282,10 @@ struct Reader<'a> {
/// The artboard's size and centre in files without artboards or crop marks.
art_size: Option<(f64, f64)>,
template_center: Option<(f64, f64)>,
/// This reader reads a pattern's definition (see [`Reader::define_pattern`]).
pattern: Option<PatternHead>,
/// The tile's top left (y down) of each pattern read, by name.
pattern_tiles: HashMap<String, Point>,
}
/// Operators that change nothing the reader keeps.
@@ -281,6 +307,8 @@ const IGNORED: &[&str] = &[
"Xm",
"XP",
"Np",
// Flatness (`i`): how finely curves print.
"i",
"TE",
"TZ",
"Xs",
@@ -377,6 +405,8 @@ impl<'a> Reader<'a> {
slot_names: HashMap::new(),
art_size: None,
template_center: None,
pattern: None,
pattern_tiles: HashMap::new(),
}
}
@@ -429,6 +459,12 @@ impl<'a> Reader<'a> {
let line = String::from_utf8_lossy(c);
let line = line.trim_end();
let m = marker(line);
// A pattern's definition (one inside another's isn't read).
if m == "BeginPattern" && self.pattern.is_none() {
let section = self.section("EndPattern")?;
self.define_pattern(section);
return Ok(());
}
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
return self.skip_to(end);
}
@@ -481,10 +517,18 @@ impl<'a> Reader<'a> {
/// Skip to the comment with marker `end` (sections of the same kind nested in it count, and
/// versioned content in an alternate).
fn skip_to(&mut self, end: &str) -> Result<(), String> {
self.section(end).map(|_| ())
}
/// [`Self::skip_to`] → what was skipped, up to the comment that ends it.
fn section(&mut self, end: &str) -> Result<&'a [u8], String> {
let begin = SKIPPED.iter().find(|(_, e)| *e == end).map_or("", |(b, _)| *b);
let begin = if end == "End_Versioned_Content" { "Begin_Content_if_version_gt" } else { begin };
let start = self.lex.offset();
let mut depth = 0usize;
while let Some(t) = self.lex.next_token() {
loop {
let before = self.lex.offset();
let Some(t) = self.lex.next_token() else { break };
if let Tok::Comment(c) = t.tok {
let line = String::from_utf8_lossy(c);
let m = marker(&line);
@@ -492,7 +536,7 @@ impl<'a> Reader<'a> {
depth += 1;
} else if m == end {
if depth == 0 {
return Ok(());
return Ok(self.lex.slice(start, before));
}
depth -= 1;
}
@@ -501,6 +545,58 @@ impl<'a> Reader<'a> {
Err(format!("its section ending with `%{end}` doesn't end"))
}
/// A pattern's definition (`section`: what follows `%AI3_BeginPattern`), read by a reader of its
/// own into a pattern swatch, its art in the pattern's space (y down). A definition it can't
/// read whole (an operator it doesn't know, type, unbalanced groups, no tile) isn't kept: art
/// filled with it can't be read.
fn define_pattern(&mut self, section: &[u8]) {
if self.pattern_tiles.len() >= MAX_PATTERNS {
return;
}
let mut sub = Reader::new(section);
sub.pattern = Some(PatternHead::default());
// The gradients defined so far, lent (and those it defines kept).
sub.gradients = std::mem::take(&mut self.gradients);
sub.nodes = self.nodes;
let read = sub.open(Kind::Group, false).and_then(|()| sub.run());
self.nodes = sub.nodes;
self.gradients = std::mem::take(&mut sub.gradients);
let whole = read.is_ok() && sub.unsupported.is_empty() && sub.unknown.is_empty() && sub.frames.len() == 1;
let (Some(head), Some(frame)) = (sub.pattern.take(), sub.frames.pop()) else { return };
let Some(tile) = head.tile.filter(|t| whole && sane(*t) && t.width() > 0.0 && t.height() > 0.0 && !frame.children.is_empty()) else {
return;
};
let art: Vec<Arc<Node>> = frame.children.iter().map(|n| Arc::new(self.doc.reid(n))).collect();
let mut def = PatternDef::new(&head.name, art);
def.tile = FLIP.transform_rect_bbox(tile);
if self.pattern_tiles.insert(def.name.clone(), def.tile.origin()).is_some() {
self.doc.patterns.retain(|p| p.name != def.name);
}
self.doc.patterns.push(def);
for (key, blob) in sub.doc.images {
self.doc.images.entry(key).or_insert(blob);
}
self.spots.extend(sub.spots);
self.cmyk_colors += sub.cmyk_colors;
self.rgb_colors += sub.rgb_colors;
for w in &sub.warnings {
self.warn(w);
}
}
/// In a pattern's definition, the operands of its first operator start with its head,
/// `(name) llx lly urx ury`: taken off them.
fn take_head(&mut self, mut vals: Vec<V>) -> Vec<V> {
let Some(head) = self.pattern.as_mut().filter(|h| !h.taken) else { return vals };
head.taken = true;
let Some(at) = vals.iter().position(|v| matches!(v, V::Str(_))) else { return vals };
let tile = Self::nums(vals.get(at + 1..at + 5).unwrap_or_default());
let ([x0, y0, x1, y1], Some(name)) = (tile.as_slice(), vals.get(at).and_then(V::text)) else { return vals };
head.name = name;
head.tile = Some(Rect::new(*x0, *y0, *x1, *y1));
vals.split_off(at + 5)
}
// ---- frames ----------------------------------------------------------------------------
fn open(&mut self, kind: Kind, hidden: bool) -> Result<(), String> {
@@ -682,6 +778,7 @@ impl<'a> Reader<'a> {
self.after_object = false;
}
let vals = self.operands();
let vals = self.take_head(vals);
if let Some(d) = self.gradient_def.as_mut() {
match w {
"Bs" => d.stop(&vals, &mut self.spots),
@@ -728,8 +825,10 @@ impl<'a> Reader<'a> {
self.paint(&op, hidden, true)?;
}
// Colours.
"g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" => self.color(w, &vals),
"p" | "P" => self.unreadable("pattern fills"),
"g" | "G" | "k" | "K" | "x" | "X" | "Xa" | "XA" | "Xx" | "XX" | "Xk" | "XK" => self.color(w, &vals),
"p" | "P" => self.pattern_paint(w, &vals),
// A pattern's definition ends.
"E" if self.pattern.is_some() => self.done = true,
"O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0),
"R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0),
"XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero },
@@ -856,6 +955,9 @@ impl<'a> Reader<'a> {
/// A text object (`/AI11Text`): an empty group standing for it, named after its story.
fn text_slot(&mut self, o: &Obj) -> Result<(), String> {
if self.pattern.is_some() {
self.unreadable("type in a pattern");
}
let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0));
let gs = self.gs.clone();
@@ -893,7 +995,7 @@ impl<'a> Reader<'a> {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
}
let target = self.ensure_space().transform_rect_bbox(art_box);
let target = self.space().transform_rect_bbox(art_box);
let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() };
let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) {
Ok(r) => r,
@@ -1001,7 +1103,8 @@ impl<'a> Reader<'a> {
}
}
/// `… Lb`: a layer's options.
/// `… Lb`: a layer's options. The legacy format's has ten operands, its colour index one place
/// earlier (`visible preview unlocked printing dimmed masks colorIndex r g b`).
fn layer_attrs(&mut self, vals: &[V]) {
let n = Self::nums(vals);
let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() else { return };
@@ -1012,15 +1115,44 @@ impl<'a> Reader<'a> {
a.printable = flag(3, true);
let dim_pct = n.get(12).copied().filter(|v| v.is_finite()).unwrap_or(50.0).clamp(0.0, 100.0);
a.dim = flag(4, false).then_some(dim_pct.round() as u8);
let index = n.get(7).copied().unwrap_or(0.0);
let rgb = |i: usize| n.get(i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
let at = if n.len() <= 10 { 6 } else { 7 };
let index = n.get(at).copied().unwrap_or(0.0);
let rgb = |i: usize| n.get(at + i).map_or(0, |v| v.clamp(0.0, 255.0) as u8);
a.color = Some(if (0.0..vectorcraft_doc::LAYER_COLORS.len() as f64).contains(&index) && index.fract() == 0.0 {
LayerColor::Preset(index as u8)
} else {
LayerColor::Custom([rgb(8), rgb(9), rgb(10)])
LayerColor::Custom([rgb(1), rgb(2), rgb(3)])
});
}
/// `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`: the stroke's):
/// the pattern swatch `name`, its space taken to art space by the matrix. A pattern this hasn't
/// read can't be drawn.
fn pattern_paint(&mut self, w: &str, vals: &[V]) {
// In the setup it paints nothing (and the art's space isn't fixed yet).
if self.frames.is_empty() {
return;
}
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None });
let Some(origin) = name.as_deref().and_then(|n| self.pattern_tiles.get(n)).map(|p| p.to_vec2()) else {
self.unreadable("pattern fills");
return;
};
let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None });
// The tile's art (y down from the tile's top left) → the pattern's space → art space.
let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate(origin);
if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) {
self.unreadable("pattern fills");
return;
}
let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf };
if w == "p" {
self.gs.fill = p;
} else {
self.gs.stroke = p;
}
}
fn color(&mut self, w: &str, vals: &[V]) {
let Some((p, named)) = paint::color_op(w, vals) else { return };
match p.color() {
@@ -1031,7 +1163,7 @@ impl<'a> Reader<'a> {
if let Some(n) = named {
self.spots.push(n);
}
if w.chars().all(|c| c.is_ascii_lowercase()) || w == "Xa" || w == "Xx" {
if w.ends_with(|c: char| c.is_ascii_lowercase()) {
self.gs.fill = p;
} else {
self.gs.stroke = p;
@@ -1165,7 +1297,7 @@ impl<'a> Reader<'a> {
if op.chars().next().is_some_and(|c| c.is_ascii_lowercase()) {
close(&mut bp);
}
let to_doc = self.ensure_space();
let to_doc = self.space();
let path = PathData::from_bezpath(&(to_doc * bp));
let gs = self.gs.clone();
let (fill, stroke) = match op.to_ascii_lowercase().as_str() {
@@ -1200,7 +1332,7 @@ impl<'a> Reader<'a> {
}
fn set_instance(&mut self, i: &Instance) {
let to_doc = self.ensure_space();
let to_doc = self.space();
match i.paint(&self.gradients, to_doc) {
Some(p) if i.stroke => self.pending_stroke = Some(p),
Some(p) => self.pending_fill = Some(p),
@@ -1286,9 +1418,8 @@ impl<'a> Reader<'a> {
}
// The matrix maps pixels (y down) into art space with its y flipped.
let [a, b, c, d, tx, ty] = m.as_slice() else { return Ok(()) };
let to_doc = self.ensure_space();
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let xf = to_doc * flip * Affine::new([*a, *b, *c, *d, *tx, -*ty]);
let to_doc = self.space();
let xf = to_doc * FLIP * Affine::new([*a, *b, *c, *d, *tx, -*ty]);
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
return Ok(());
}
@@ -1314,18 +1445,36 @@ impl<'a> Reader<'a> {
(sane(r) && w > 0.0 && h > 0.0).then_some(r)
}
/// The first artboard: the `/Document`'s first, else the crop marks, else [`Self::sized_board`].
fn first_board(&self) -> Option<Rect> {
self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board())
}
/// The page of a file that says nothing of its artboard (the legacy format as other apps write
/// it): its art's bounding box.
fn bbox_board(&self) -> Option<Rect> {
let [x0, y0, x1, y1] = self.hires.or(self.bbox)?;
Some(Rect::new(x0, y0, x1, y1)).filter(|r| r.width() > 0.0 && r.height() > 0.0)
}
/// Art space → the document (fixed when the art starts, from the artboards read by then).
fn ensure_space(&mut self) -> Affine {
if let Some(m) = self.to_doc {
return m;
}
let first = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board());
let first = self.first_board().or_else(|| self.bbox_board());
let top_left = first.map_or(Point::new(0.0, 792.0), |r| Point::new(r.x0, r.y1));
let m = Affine::new([1.0, 0.0, 0.0, -1.0, -top_left.x, top_left.y]);
self.to_doc = Some(m);
m
}
/// Art space → where the art being read goes: the document, or while a pattern's definition is
/// read, the pattern's space (y down).
fn space(&mut self) -> Affine {
if self.pattern.is_some() { FLIP } else { self.ensure_space() }
}
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) && self.warnings.len() < 64 {
self.warnings.push(w.to_string());
@@ -1345,11 +1494,11 @@ impl<'a> Reader<'a> {
return Err("it has no layers".into());
}
let to_doc = self.ensure_space();
let artboard = self.artboards.first().map(|(_, r)| *r).or(self.cropmarks).or_else(|| self.sized_board());
let artboard = self.first_board();
drop_commented_copies(&mut self.doc.layers, &self.hidden_ids);
let mut doc = std::mem::replace(&mut self.doc, Document::new(1.0, 1.0));
let boards: Vec<(String, Rect)> = if self.artboards.is_empty() {
self.cropmarks.or_else(|| self.sized_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default()
artboard.or_else(|| self.bbox_board()).map(|r| vec![("Artboard 1".to_string(), r)]).unwrap_or_default()
} else {
self.artboards.clone()
};
@@ -1374,7 +1523,12 @@ impl<'a> Reader<'a> {
}
for n in &self.spots {
if doc.swatch(&n.name).is_none() {
doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: true });
doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot });
}
}
for name in doc.patterns.iter().map(|p| p.name.clone()).collect::<Vec<_>>() {
if doc.swatch(&name).is_none() {
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
}
}
let mut warnings = self.warnings;
+86
View File
@@ -132,6 +132,92 @@ fn paths_colours_and_strokes() {
assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some());
}
/// `Xk` and `XK` are global process colours: a name, a tint and whether the colour is RGB, as
/// `Xx` has them (#1025). They come in as global swatches that aren't inks, linked at their tint.
#[test]
fn global_process_colours_are_global_swatches_at_their_tint() {
let art_ = format!("0 0 0 1 (Black global) 0.4 0 Xk\n0 0 0 0 1 0.5 0 (Brand orange) 0 1 XK\n{}b\n", rect_path(0.0, 0.0, 10.0, 10.0));
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
let Paint::Solid { swatch, tint, color } = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
assert_eq!(swatch.as_deref(), Some("Black global"));
assert!((tint - 0.6).abs() < 1e-6 && matches!(color, Color::Cmyk { k, .. } if (k - 0.6).abs() < 1e-6), "{tint} {color:?}");
let stroke = a[0].appearance.stroke().unwrap().paint.clone();
assert!(
matches!(&stroke, Paint::Solid { swatch: Some(s), tint, color } if s == "Brand orange" && *tint == 1.0 && *color == Color::rgb(1.0, 0.5, 0.0)),
"{stroke:?}"
);
for name in ["Black global", "Brand orange"] {
let sw = doc.swatch(name).unwrap();
assert!(sw.global && !sw.spot, "{sw:?}");
}
assert_eq!(doc.swatch("Black global").unwrap().paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the swatch is the full colour");
}
/// A pattern's definition (`%AI3_BeginPattern`): its name and tile (8 points square here, y up),
/// then its art on `%_` lines (a group of the tile's unpainted box and a dot, a round-capped
/// stroke of no length), `E`.
const DOTS: &str = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 8 8\n%_0 A\n%_0 Xw\n%_0 Ae\n%_u\n%_0 O\n%_0 8 m\n%_8 8 L\n%_8 0 L\n%_0 0 L\n%_0 8 L\n%_n\n\
%_0 R\n%_0 0 0 1 K\n%_1 J 0 j 2 w 4 M []0 d\n%_4 4 m\n%_4 4 L\n%_S\n%_U\n%_/ArtDictionary :\n%_;\n%_\n%_9 () XW\nE\n%AI3_EndPattern\n";
/// Patterns are read into pattern swatches, and `p` fills with one (#1025): the art keeps its
/// layers, the fill places the tiles by its matrix.
#[test]
fn patterns_are_pattern_swatches_and_fill_with_them() {
let sub = |name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{art}LB\n%AI5_EndLayer--\n");
let body = format!(
"{DOTS}%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Ground) Ln\n{}{}LB\n%AI5_EndLayer--\n",
sub("Lawn", &format!("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(10.0, 20.0, 70.0, 80.0))),
sub("Paving", &format!("(Dots) 10 20 2 2 0 0 0 0 0 [2 0 0 2 10 20] P\n{}S\n", rect_path(90.0, 20.0, 150.0, 80.0))),
);
let s = read_ok(&body);
let doc = &s.doc;
let ground = &doc.layers[0];
let subs: Vec<_> = ground.children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(subs, ["Lawn", "Paving"]);
let def = doc.pattern("Dots").unwrap();
assert_eq!(def.tile, Rect::new(0.0, -8.0, 8.0, 0.0), "the tile, y down");
assert_eq!(def.art.len(), 1, "the tile's group");
let tile_box = &def.art[0].children().unwrap()[0];
assert_eq!(tile_box.geometric_bounds(), Some(def.tile), "the tile's box is where the tile is");
assert_eq!(def.art[0].children().unwrap().len(), 2);
assert!(matches!(&doc.swatch("Dots").unwrap().paint, Paint::Pattern { pattern, .. } if pattern == "Dots"));
let lawn = &ground.children().unwrap()[0].children().unwrap()[0];
let Paint::Pattern { pattern, xf } = fill(lawn) else { panic!("{:?}", fill(lawn)) };
assert_eq!(pattern, "Dots");
// The tile's top left (0, 8) in art space is (0, 92) on the page (crop marks 100 high); a dot
// centre (4, 4) is at (4, 96).
assert_eq!(xf * Point::ZERO, Point::new(0.0, 92.0));
assert_eq!(xf * Point::new(4.0, 4.0), Point::new(4.0, 96.0));
// A stroke with it, scaled twice and moved by (10, 20): the tile's top left is at (10, 36) in
// art space.
let paving = &ground.children().unwrap()[1].children().unwrap()[0];
let Some(Paint::Pattern { xf, .. }) = paving.appearance.stroke().map(|st| st.paint.clone()) else { panic!() };
assert_eq!(xf * Point::ZERO, Point::new(10.0, 64.0));
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
}
/// A pattern whose definition it can't read whole isn't kept: a fill with it is something the file
/// has that isn't read, where it shows. Its definition costs nothing else.
#[test]
fn a_pattern_it_cant_read_is_an_error_only_where_it_fills() {
let unread = "%AI3_BeginPattern: (Old)\n(Old) 0 0 8 8 [\n(0 O 0 R 0 g) @\n(\n0 0 m 8 8 L S\n) &\n] E\n%AI3_EndPattern\n\
%AI3_BeginPattern: (Odd)\n(Odd) 0 0 8 8\n%_U\nE\n%AI3_EndPattern\n%AI3_BeginPattern: (Flat)\n(Flat) 0 0 8 0\n%_0 0 m\n%_8 0 L\n%_S\nE\n%AI3_EndPattern\n";
let s = read_ok(&format!("{unread}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"))));
assert!(s.doc.patterns.is_empty() && s.doc.swatches.iter().all(|w| !matches!(w.paint, Paint::Pattern { .. })));
for name in ["Old", "Odd", "Flat"] {
let e = read(
stream(&format!("{unread}{}", layer(&format!("({name}) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n{}f\n", rect_path(0.0, 0.0, 1.0, 1.0)))))
.as_bytes(),
)
.unwrap_err();
assert!(e.contains("pattern fills"), "{name}: {e}");
}
// Type in a pattern isn't read either.
let typed = "%AI3_BeginPattern: (Words)\n(Words) 0 0 8 8\n%_/AI11Text :\n%_0 /StoryIndex ,\n%_;\nE\n%AI3_EndPattern\n";
assert!(read_ok(&format!("{typed}{}", layer(""))).doc.patterns.is_empty());
}
#[test]
fn compound_paths_and_clipping() {
let art_ = format!(
+17 -6
View File
@@ -16,14 +16,18 @@
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
//! as groups. The format's published specification documents its header comments (`%AI…`,
//! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423.
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that
//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads
//! an Illustrator EPS's editing copy (#1027).
//!
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
//! of a PostScript file without one. A file whose program can't be read (an operator the
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in as its
//! preview (a TIFF image, a Windows metafile or an EPSI bitmap), with a warning that names the
//! error, the operator and the procedures it ran in; without a preview, what was drawn before the
//! error is kept (with that warning), else the file is refused.
//! interpreter doesn't know, an error, a limit reached) or that draws nothing comes in from its
//! layers when its program has them (a `.ai` in PostScript form whose prolog is named but not
//! included, as other apps write it), else as its preview (a TIFF image, a Windows metafile or an
//! EPSI bitmap), with a warning that names the error, the operator and the procedures it ran in;
//! without a preview, what was drawn before the error is kept (with that warning), else the file
//! is refused.
mod ai;
mod ate;
@@ -201,6 +205,13 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
(Err(e), _) => reason(e, fault.as_ref()),
};
// A file in the legacy Illustrator format whose program can't be run (a `.ai` that names the
// prolog defining its operators without including it): its layers, which the program has.
let why = match editing_data.then(|| native::program_alone(ps)).flatten() {
Some(Ok((document, warnings))) => return Ok(Imported { document, warnings, preview: false }),
Some(Err(e)) => format!("{why}; its layers couldn't be read either ({e})"),
None => why,
};
if let Some(p) = preview::preview(bytes, ps, frame) {
let warnings = vec![format!("this file's PostScript couldn't be read ({why}): {}", p.what)];
return Ok(Imported { document: p.document, warnings, preview: true });
@@ -214,7 +225,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
out.warn("the file draws nothing");
Ok(finish(out))
}
Err(e) => Err(format!("this PostScript file can't be read ({}): save it as PDF or SVG in the app that made it", reason(&e, fault.as_ref()))),
Err(_) => Err(format!("this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it")),
}
}
+35 -8
View File
@@ -1,11 +1,15 @@
//! The layers of an Illustrator EPS or `.ai`.
//!
//! Besides the page it prints, such a file carries the app's own copy of the art: an EPS after its
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). It has what the
//! page doesn't: the layers with their names and options, the groups, compound paths and clipping
//! groups, the objects' names, the hidden objects and layers, the art outside the artboards and
//! every artboard. `ai` reads that structure into a document, with the colours, gradients,
//! transparency and images of the art.
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). A file in the
//! legacy format (a `.ai` in PostScript form: versions 3 to 8, and the apps that still write it)
//! is that copy itself: its program has the layers, and the prolog it names defines the operators
//! for printing (other apps name it without including it, so their files can't be run).
//!
//! The copy has what the page doesn't: the layers with their names and options, the groups,
//! compound paths and clipping groups, the objects' names, the hidden objects and layers, the art
//! outside the artboards and every artboard. `ai` reads that structure into a document, with the
//! colours, gradients, transparency and images of the art.
//!
//! Text objects are slots in that document: their characters, fonts and places are in the file's
//! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot
@@ -21,6 +25,7 @@
//!
//! Where a file doesn't match what this module expects, it is left alone and its page is imported.
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::sync::Arc;
@@ -991,12 +996,13 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result
}))
}
/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the
/// The EPS `visible` (read as its page) read through its editing copy instead (its private data, or
/// its program in the legacy format, see [`editing_copy`]): its layers (the
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
/// has one that can't be used, `visible` with a warning that says why.
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
let Some(data) = ai::eps_data(ps) else { return visible };
let Some(data) = editing_copy(ps) else { return visible };
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false));
match result {
Ok((document, mut warnings)) => {
@@ -1023,7 +1029,7 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
/// its PDF part).
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool) -> (Document, Vec<String>) {
let Ok(data) = ai::decode_private(private) else { return (visible, warnings) };
if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") {
if !has_layers(&data) {
return (visible, warnings);
}
match build(&data, Some(&visible), Page::Artboard, outlined) {
@@ -1047,6 +1053,27 @@ pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
build(&data, None, Page::Artboard, false)
}
/// Does the editing data (or a program in the legacy format) have layers?
fn has_layers(data: &[u8]) -> bool {
data.windows(15).any(|w| w == b"%AI5_BeginLayer")
}
/// The editing copy of the art in the PostScript `ps`: an EPS's private data, else the program
/// itself when it has layers (the legacy format). `None` without one.
fn editing_copy(ps: &[u8]) -> Option<Result<Cow<'_, [u8]>, String>> {
match ai::eps_data(ps) {
Some(data) => Some(data.map(Cow::Owned)),
None => has_layers(ps).then_some(Ok(Cow::Borrowed(ps))),
}
}
/// A file in the legacy format whose page can't be read (a `.ai` in PostScript form that names its
/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the
/// document and the import's notes. `None` when the layers aren't in the program.
pub(super) fn program_alone(ps: &[u8]) -> Option<Result<(Document, Vec<String>), String>> {
(!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false))
}
#[cfg(test)]
mod tests {
use super::*;
+76 -1
View File
@@ -4,7 +4,8 @@
use std::sync::Arc;
use vectorcraft_doc::clipnest::MAX_NEST;
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{LayerColor, LineCap, Node, NodeKind};
use vectorcraft_geom::Rect;
use crate::import::import;
@@ -80,6 +81,80 @@ fn other_postscript_keeps_its_own_u() {
assert_eq!(read("%%Creator: some app\n", "", &body).0, "p p");
}
/// A `.ai` in PostScript form as other apps write it (#1027): its header and prolog name the
/// procsets that define its operators (by the DSC resource comments) without including them, so
/// its program can't be run. Its layers come from the program itself: names as they are, the
/// legacy format's ten-operand `Lb`, flatness `i`, the bounding box as the page.
const NAMED_PROLOG: &str = "%!PS-Adobe-3.0\n%%Creator: a CAD app\n%%BoundingBox: 0 0 200 100\n\
%%DocumentNeededResources: procset Adobe_packedarray 2.0 0\n%%+ procset Adobe_IllustratorA_AI3 1.0 0\n\
%AI3_TemplateBox: 288 384 288 384\n%%EndComments\n%%BeginProlog\n%%IncludeResource: procset Adobe_packedarray 2.0 0\n\
Adobe_packedarray /initialize get exec\n%%IncludeResource: procset Adobe_IllustratorA_AI3 1.0 0\n%%EndProlog\n\
%%BeginSetup\nAdobe_IllustratorA_AI3 /initialize get exec\n%%EndSetup\n\
%AI5_BeginLayer\n1 1 1 1 0 0 -1 0 0 0 Lb\n(SECTION::Cut) Ln\n0 A\n0 R\n0 0 0 1 K\n0 i 1 J 1 j 0.8 w 4 M []0 d\n0 D\n\
10 10 m\n90 10 L\n90 90 L\n10 90 L\n10 10 L\nS\nLB\n%AI5_EndLayer--\n\
%AI5_BeginLayer\n1 1 1 1 0 0 -1 191 63 255 Lb\n(SECTION::Hatch) Ln\n0 A\n0 R\n0 0 0 1 K\n0.1 w\n110 10 m\n190 90 L\nS\nLB\n%AI5_EndLayer--\n\
%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\nAdobe_IllustratorA_AI3 /terminate get exec\n%%EOF\n";
#[test]
fn a_postscript_ai_that_names_its_prolog_without_including_it_opens_with_its_layers() {
let r = import(NAMED_PROLOG.as_bytes()).unwrap();
assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings);
let d = &r.document;
let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["SECTION::Cut", "SECTION::Hatch"]);
let colors: Vec<_> = d.layers.iter().map(|l| if let NodeKind::Layer { color, .. } = l.kind { Some(color) } else { None }).collect();
assert_eq!(colors, [Some(LayerColor::Custom([0, 0, 0])), Some(LayerColor::Custom([191, 63, 255]))]);
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), "the page is the bounding box");
let square = &d.layers[0].children().unwrap()[0];
assert_eq!(square.geometric_bounds(), Some(Rect::new(10.0, 10.0, 90.0, 90.0)));
let stroke = square.appearance.stroke().unwrap();
assert_eq!((stroke.width, stroke.cap), (0.8, LineCap::Round));
assert_eq!(tree(d.layers[1].children().unwrap()), "p");
}
/// A `.ai` in PostScript form whose layers can't be read either says why, after what stopped its
/// program.
#[test]
fn a_postscript_ai_whose_layers_cant_be_read_says_why() {
let damaged = NAMED_PROLOG.replace("0 D\n", "0 D\n1 2 frobnicate\n");
let e = import(damaged.as_bytes()).unwrap_err();
assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}");
}
/// The editing text of a newer file saved as PostScript (no prolog at all, a non-printing setup):
/// it opens with its sublayers.
#[test]
fn newer_editing_text_as_postscript_opens_with_its_sublayers() {
let layer =
|name: &str, art: &str| format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 -1 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}LB\n%AI5_EndLayer--\n");
let ground = layer("Ground", &(layer("Lawn", "0 0 0 0.3 k\n10 20 m\n70 20 L\n70 80 L\n10 80 L\nf\n") + &layer("Paving", "")));
let text = format!(
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 250 100\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 250 100\n%%EndComments\n\
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%AI5_Begin_NonPrinting\nNp\n%AI5_End_NonPrinting--\n%%EndSetup\n{ground}%%PageTrailer\n%%Trailer\n%%EOF\n"
);
let r = import(text.as_bytes()).unwrap();
let d = &r.document;
assert_eq!(d.layers.len(), 1, "{:?}", r.warnings);
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(subs, ["Lawn", "Paving"]);
}
/// A file in the legacy format whose prolog is included (here one of our own) runs as its page, and
/// comes in through the layers its program has when they look like the page.
#[test]
fn a_legacy_file_with_its_prolog_comes_in_through_its_layers() {
let prolog = "/Lb {10 {pop} repeat} def /Ln {pop} def /LB {} def /A {pop} def /k {setcmykcolor} def";
let body = format!("%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Back) Ln\n0 A\n0 0 0 1 k\n{} LB\n%AI5_EndLayer--\n", tri(10));
let file = String::from_utf8(ai(AI8, "", &body)).unwrap().replace("%%EndProlog", &format!("{prolog}\n%%EndProlog"));
let r = import(file.as_bytes()).unwrap();
let names: Vec<_> = r.document.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Back"], "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
// Without the editing data, it is its page.
let page = crate::import::import_with(file.as_bytes(), false).unwrap();
assert_eq!(page.document.layers[0].name.as_deref(), Some("Layer 1"));
}
#[test]
fn unbalanced_and_runaway_groups_are_read_safely() {
// Ends without a beginning end nothing; groups left open hold the rest.
+27
View File
@@ -543,6 +543,33 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
}
/// A global process colour (`Xk`) or a pattern fill on a sublayer no longer sends a `.ai` to its
/// PDF part, which has only the top-level layers (#1025).
#[test]
fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() {
// The PDF part's yellow square, in a global yellow at full tint, on a sublayer; a hidden
// sublayer with a pattern fill (the page doesn't have it).
let yellow = "0 0 1 0 (Yellow global) 0 0 Xk\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf";
let dots = "%AI3_BeginPattern: (Dots)\n(Dots) 0 0 4 4\n%_0 0 0 1 k\n%_1 1 m\n%_3 1 L\n%_3 3 L\n%_1 3 L\n%_f\nE\n%AI3_EndPattern\n";
let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned)));
let art = native(&format!("{dots}{ground}"));
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false);
assert_eq!(names(&d), ["Ground"], "{warnings:?}");
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect();
assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(d.swatch("Yellow global").is_some_and(|s| s.global && !s.spot));
assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some());
// Without a PDF part, the pattern fill shows.
let shown = native(&format!("{dots}{}", layer("Ground", true, &layer("Lawn", true, patterned))));
let (d, notes) = crate::ai_alone(&private(&shown)).unwrap();
let lawn = &d.layers[0].children().unwrap()[0];
assert_eq!((lawn.name.as_deref(), kinds(lawn)), (Some("Lawn"), vec!["path"]), "{notes:?}");
let fill = lawn.children().unwrap()[0].appearance.fill().map(|f| f.paint.clone());
assert!(matches!(&fill, Some(vectorcraft_color::Paint::Pattern { pattern, .. }) if pattern == "Dots"), "{fill:?}");
}
#[test]
fn an_object_the_page_doesnt_draw_makes_the_file_come_in_as_its_page() {
// A 20-point square that the page doesn't draw: 4% of the page, and an object all the same.
+70 -2
View File
@@ -1,18 +1,60 @@
//! Hit testing primitives.
use kurbo::{BezPath, ParamCurveNearest, Point, Rect, Shape};
use std::borrow::Cow;
use kurbo::{BezPath, ParamCurveNearest, PathEl, Point, Rect, Shape};
use crate::path::{FillRule, PathData};
/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled).
pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool {
let w = path.winding(p);
let w = closed(path).winding(p);
match rule {
FillRule::NonZero => w != 0,
FillRule::EvenOdd => w % 2 != 0,
}
}
/// `path` with each open subpath closed by a straight line back to its start, the edge its fill
/// has. kurbo's winding leaves that edge out, so a point beside it counted as outside the fill:
/// an open path lost its fill clicks on that side (a reflection moved the edge to the other one).
fn closed(path: &BezPath) -> Cow<'_, BezPath> {
let mut open = false;
let mut needs = false;
for el in path.elements() {
match el {
PathEl::MoveTo(_) => {
needs |= open;
open = true;
}
PathEl::ClosePath => open = false,
_ => {}
}
}
if !(needs || open) {
return Cow::Borrowed(path);
}
let mut out = BezPath::new();
let mut open = false;
for &el in path.elements() {
match el {
PathEl::MoveTo(_) => {
if open {
out.close_path();
}
open = true;
}
PathEl::ClosePath => open = false,
_ => {}
}
out.push(el);
}
if open {
out.close_path();
}
Cow::Owned(out)
}
/// Distance from `p` to the nearest point on the path outline.
pub fn distance_to_outline(path: &BezPath, p: Point) -> f64 {
path.segments().map(|s| s.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt()
@@ -77,6 +119,32 @@ mod tests {
assert!(fill_contains(&bp, FillRule::EvenOdd, Point::new(1.0, 5.0)));
}
/// An open path fills as if closed by a straight line back to its start, and is hit there,
/// whichever side that line falls on (a reflected open path lost its fill clicks).
#[test]
fn open_paths_fill_as_closed_on_either_side() {
// A filled sliver left open by a deleted anchor; its closing line runs down its left side.
let open = BezPath::from_svg(
"M86.311 69.612C100.544 78.261 110.506 88.64 110.506 100.748C110.506 111.127 100.544 118.046 86.311 118.046C93.214 102.045 91.407 102.403 91.407 97.61",
)
.unwrap();
let mut closed = open.clone();
closed.close_path();
for p in [Point::new(100.0, 100.0), Point::new(95.0, 85.0), Point::new(92.0, 112.0)] {
assert!(closed.winding(p) != 0, "{p:?} is inside the closed sliver");
assert!(fill_contains(&open, FillRule::NonZero, p), "{p:?}: the open sliver's fill");
assert!(fill_contains(&open, FillRule::EvenOdd, p), "{p:?}: even-odd too");
}
assert!(!fill_contains(&open, FillRule::NonZero, Point::new(80.0, 100.0)), "left of the closing line");
// Mirrored, the closing line is on the other side: still filled.
let flipped = kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, 200.0, 0.0]) * open.clone();
assert!(fill_contains(&flipped, FillRule::NonZero, Point::new(100.0, 100.0)));
// Two open subpaths: each closes on its own.
let mut two = open.clone();
two.extend(kurbo::Affine::translate((0.0, 100.0)) * open.clone());
assert!(fill_contains(&two, FillRule::NonZero, Point::new(100.0, 100.0)) && fill_contains(&two, FillRule::NonZero, Point::new(100.0, 200.0)));
}
#[test]
fn marquee() {
let p = shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0));
+7 -2
View File
@@ -235,8 +235,9 @@ pub fn polar_grid(r: Rect, concentric: u32, radial: u32) -> Vec<PathData> {
let f = i as f64 / (concentric + 1) as f64;
out.push(ellipse(Rect::from_center_size(c, (r.width() * f, r.height() * f))));
}
for i in 0..radial.max(1) {
let a = -PI / 2.0 + TAU * i as f64 / radial.max(1) as f64;
// No dividers at 0 (the loop never divides by it).
for i in 0..radial {
let a = -PI / 2.0 + TAU * i as f64 / radial as f64;
out.push(line(c, Point::new(c.x + r.width() / 2.0 * a.cos(), c.y + r.height() / 2.0 * a.sin())));
}
out
@@ -402,6 +403,10 @@ mod tests {
fn grids() {
assert_eq!(rectangular_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5, true).len(), 11);
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 5, 5).len(), 11);
// #1008: no radial dividers is none, only the outer ellipse without concentric ones.
for (radial, len) in [(0, 1), (1, 2), (2, 3)] {
assert_eq!(polar_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, radial).len(), len, "{radial}");
}
}
#[test]
+144 -4
View File
@@ -235,12 +235,16 @@ pub(crate) struct Tidy {
keep: Vec<Point>,
/// Input curves (with inflated bounds) used to rebuild split pieces exactly.
curves: Vec<(kurbo::Rect, CubicBez)>,
/// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose
/// bounds can hold a point, without testing them all.
by_x: Vec<usize>,
max_width: f64,
}
impl Tidy {
/// Merge any smooth joint.
pub(crate) fn free(precision: f64) -> Self {
Self { precision, keep: Vec::new(), curves: Vec::new() }
Self { precision, ..Default::default() }
}
/// Keep every on-curve point of `paths`.
pub(crate) fn keeping<'a>(precision: f64, paths: impl IntoIterator<Item = &'a BezPath>) -> Self {
@@ -257,7 +261,10 @@ impl Tidy {
}
}
keep.sort_by(|a, b| a.x.total_cmp(&b.x));
Self { precision, keep, curves }
let mut by_x: Vec<usize> = (0..curves.len()).collect();
by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0));
let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max);
Self { precision, keep, curves, by_x, max_width }
}
fn is_kept(&self, p: Point) -> bool {
let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs()));
@@ -322,10 +329,11 @@ impl Arrangement {
pub(crate) fn contours_to_path(c: &Contours, idx: impl IntoIterator<Item = ContourIdx>, tidy: &Tidy) -> PathData {
let mut subpaths = Vec::new();
for i in idx {
if is_sliver(&c[i].path, tidy.precision) {
let path = tidy.repair(&c[i].path);
if is_sliver(&path, tidy.precision) {
continue;
}
if let Some(sp) = tidy_bezpath(&c[i].path, tidy) {
if let Some(sp) = tidy_bezpath(&path, tidy) {
subpaths.push(sp);
}
}
@@ -515,6 +523,84 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option<CubicBez> {
None
}
impl Tidy {
/// The input curves whose bounds hold both `a` and `b`.
fn curves_around(&self, a: Point, b: Point) -> impl Iterator<Item = &CubicBez> {
let x = a.x.min(b.x);
let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x);
let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width);
self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c)
}
/// `bp` with each curve piece that strays from the one input curve through both its ends
/// rebuilt as that curve's exact sub-curve. linesweeper 0.5 can emit a piece that doesn't
/// follow its curve; out and back between two points of one curve, such pieces are a lens
/// instead of nothing (issue #1028). A piece on a curve, or whose ends two different input
/// curves pass through (a real lens between them), is left as it is.
pub(crate) fn repair(&self, bp: &BezPath) -> BezPath {
use kurbo::{ParamCurve, ParamCurveNearest, PathEl};
if self.curves.is_empty() {
return bp.clone();
}
let mut out = BezPath::new();
let mut cur = Point::ZERO;
let mut start = Point::ZERO;
for el in bp.iter() {
let piece = match el {
PathEl::QuadTo(c, p) => Some(kurbo::QuadBez::new(cur, c, p).raise()),
PathEl::CurveTo(c1, c2, p) => Some(CubicBez::new(cur, c1, c2, p)),
_ => None,
};
match (el, piece) {
(_, Some(raw)) => {
let (a, b) = (raw.p0, raw.p3);
let scale = 1.0 + a.x.abs().max(a.y.abs()).max(b.x.abs()).max(b.y.abs());
let tol = 1e-6 * scale;
let mut subs: Vec<CubicBez> = vec![];
let mut follows = false;
for c in self.curves_around(a, b) {
let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12));
if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 {
continue;
}
if [0.25, 0.5, 0.75].iter().all(|&t| c.nearest(raw.eval(t), 1e-12).distance_sq.sqrt() <= tol * 10.0) {
follows = true;
break;
}
let sub = if na.t < nb.t {
c.subsegment(na.t..nb.t)
} else {
let r = c.subsegment(nb.t..na.t);
CubicBez::new(r.p3, r.p2, r.p1, r.p0)
};
subs.push(CubicBez::new(a, sub.p1, sub.p2, b));
}
let same = |p: &CubicBez, q: &CubicBez| p.p1.distance(q.p1) <= tol * 10.0 && p.p2.distance(q.p2) <= tol * 10.0;
match subs.split_first() {
Some((sub, rest)) if !follows && rest.iter().all(|q| same(sub, q)) => out.curve_to(sub.p1, sub.p2, b),
_ => out.push(el),
}
cur = b;
}
(PathEl::MoveTo(p), None) => {
out.push(el);
(cur, start) = (p, p);
}
(PathEl::LineTo(p), None) => {
out.push(el);
cur = p;
}
(PathEl::ClosePath, None) => {
out.push(el);
cur = start;
}
(_, None) => out.push(el),
}
}
out
}
}
/// Resolve self-intersections / overlaps of a single filled path into simple, consistently
/// oriented contours (outer contours and holes).
pub fn normalize(path: &PathData, rule: FillRule) -> PathData {
@@ -592,3 +678,57 @@ pub fn area(path: &PathData, rule: FillRule) -> f64 {
Err(_) => 0.0,
}
}
#[cfg(test)]
mod tests {
use kurbo::ParamCurve;
use super::*;
/// A quarter-circle-like arc, the input curve the pieces below are cut from.
fn arc() -> CubicBez {
CubicBez::new((0.0, 0.0), (0.0, 55.0), (45.0, 100.0), (100.0, 100.0))
}
#[test]
fn a_stray_piece_out_and_back_along_one_curve_is_no_outline() {
// Issue #1028: linesweeper 0.5 handed back a contour running out along an input curve and
// back by a piece that doesn't follow it, which refit into a visible lens.
let c = arc();
let tidy =
Tidy::keeping(DEFAULT_PRECISION, [&BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)])]);
let out = c.subsegment(0.2..0.6);
let mut contour = BezPath::new();
contour.move_to(out.p0);
contour.curve_to(out.p1, out.p2, out.p3);
// back to the start, bulging off the curve and overshooting the end like the reported piece
contour.curve_to(out.p0 + (out.p0 - out.p3) * 0.15 + kurbo::Vec2::new(4.0, -3.0), out.p1 + kurbo::Vec2::new(3.0, -2.0), out.p0);
contour.close_path();
assert!(!is_sliver(&contour, tidy.precision), "the stray piece makes a lens");
let fixed = tidy.repair(&contour);
assert!(is_sliver(&fixed, tidy.precision), "{}", fixed.to_svg());
// a piece on its curve is left exactly as it is
let mut on = BezPath::new();
on.move_to(out.p0);
on.curve_to(out.p1, out.p2, out.p3);
on.line_to(out.p0);
on.close_path();
assert_eq!(tidy.repair(&on), on);
}
#[test]
fn a_lens_between_two_curves_through_the_same_points_stays() {
// Two input curves meeting at both ends bound a real face: neither piece is "repaired"
// onto the other.
let lower = CubicBez::new((0.0, 0.0), (30.0, 20.0), (70.0, 20.0), (100.0, 0.0));
let upper = CubicBez::new((0.0, 0.0), (30.0, -20.0), (70.0, -20.0), (100.0, 0.0));
let input = |c: CubicBez| BezPath::from_vec(vec![kurbo::PathEl::MoveTo(c.p0), kurbo::PathEl::CurveTo(c.p1, c.p2, c.p3)]);
let tidy = Tidy::keeping(DEFAULT_PRECISION, [&input(lower), &input(upper)]);
let mut lens = BezPath::new();
lens.move_to(lower.p0);
lens.curve_to(lower.p1, lower.p2, lower.p3);
lens.curve_to(upper.p2, upper.p1, upper.p0);
lens.close_path();
assert_eq!(tidy.repair(&lens), lens);
}
}
+25
View File
@@ -192,4 +192,29 @@ mod tests {
// Ring 16·20 − 8·12 = 224, plus the bar outside it (10·4) and inside its hole (6·4).
assert!((area - (224.0 + 40.0 + 24.0)).abs() < 1e-6, "{area}");
}
#[test]
fn miter_offset_finishes_where_the_sweep_used_to_loop() {
// Issue #1028: with linesweeper 0.4 the sweep over this miter offset never finished and
// its memory grew without bound, freezing the app. A synthetic outline (found by search)
// with the same failure as the reported glyph; the exact coordinates matter.
const OUTLINE: &str = "M74.359137297823,82.83738194552835 L88.097432485874,103.04979803227248 \
L67.06936731196308,105.95779886823588 L53.29382873396223,98.53943925153727 \
L29.684713319694094,94.17996020306241 L41.65480824807079,75.639672165115 \
Q47.491034578751446,52.12555333014565 46.157302824282816,55.67193079127421 \
Q69.08365151786553,53.9608681393331 68.37499772324333,51.17242466680129 \
Q74.4136309339628,75.13284223919301 71.3309887459222,73.69128869058812 Z";
let path = PathData::from_bezpath(&BezPath::from_svg(OUTLINE).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
let input = path.clone();
std::thread::spawn(move || tx.send(offset_path(&input, 1.8350459854022054, Join::Miter, 4.0)));
let Ok(out) = rx.recv_timeout(std::time::Duration::from_secs(30)) else {
// the stuck sweep keeps allocating on its thread: end the test binary now
eprintln!("Offset Path didn't finish in 30 s (issue #1028)");
std::process::exit(101);
};
let (before, after) = (crate::area(&path, FillRule::NonZero), crate::area(&out, FillRule::NonZero));
assert_eq!(out.subpaths.len(), 1);
assert!(after > before, "{before} → {after}");
}
}
+11 -5
View File
@@ -21,7 +21,7 @@ use vectorcraft_geom::{FillRule, PathData};
use crate::import_color::{Colors, Native};
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
@@ -692,8 +692,9 @@ struct Builder<'p> {
/// Mask keys of CMYK images whose mask turned out to hide nothing.
opaque: HashSet<u128>,
warnings: Vec<String>,
/// Fonts by cache key → base font name (from [`scan_page`]).
fonts: HashMap<u128, String>,
/// Fonts by cache key → base font name and whether the file carries its glyphs (from
/// [`scan_page`]).
fonts: FontNames,
font_names: HashMap<u128, FontInfo>,
families: Option<Families>,
missing_fonts: Vec<String>,
@@ -1346,7 +1347,7 @@ impl<'p> Builder<'p> {
return n.clone();
}
let data = o.font_data();
let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
let name = self.fonts.get(&key).map(|(n, _)| n.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic));
// A subset's six-letter tag.
let ps = name.as_deref().map(|n| match n.split_once('+') {
@@ -1429,7 +1430,12 @@ impl<'p> Builder<'p> {
let upright = width.is_some_and(|w| w.abs() <= 1.0);
let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0);
let mut info = self.font_name(key, o);
if let Some(face) = &info.face {
// A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which
// style of the installed family it is, so the name decides.
let embedded = self.fonts.get(&key).is_none_or(|(_, e)| *e);
if let Some(face) = &info.face
&& embedded
{
let decided = match self.matched.get(&key) {
Some(m) => m.clone(),
// A glyph that can't be compared (several characters, a vertical form) decides
+31 -6
View File
@@ -389,12 +389,16 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
Object::Array(a) => {
let mut it = a.flex_iter();
match it.next::<Name<'_>>()?.as_str() {
"ICCBased" => match it.next::<Stream<'_>>()?.dict().get::<u8>(b"N")? {
1 => Some(Alt::Gray),
3 => Some(Alt::Rgb),
4 => Some(Alt::Cmyk),
_ => None,
},
"ICCBased" => {
let profile = it.next::<Stream<'_>>()?;
match profile.dict().get::<u8>(b"N")? {
1 => Some(Alt::Gray),
3 if lab_profile(&profile) => Some(Alt::Lab),
3 => Some(Alt::Rgb),
4 => Some(Alt::Cmyk),
_ => None,
}
}
"Lab" => Some(Alt::Lab),
"CalRGB" => Some(Alt::Rgb),
"CalGray" => Some(Alt::Gray),
@@ -406,6 +410,27 @@ fn alt_model(o: &Object<'_>) -> Option<Alt> {
}
}
/// Is the ICC profile of an `ICCBased` space a Lab one (its alternate space `Lab`, else its header's
/// data colour space `Lab `)? Spot colours defined in Lab come with such a profile as their
/// alternate space (#1032), and their tint transforms give Lab values.
fn lab_profile(profile: &Stream<'_>) -> bool {
use std::io::Read;
if let Some(Object::Array(a)) = profile.dict().get::<Object<'_>>(b"Alternate") {
return a.flex_iter().next::<Name<'_>>().is_some_and(|n| n.as_str() == "Lab");
}
// The header's first 20 bytes, without decoding the rest of the profile.
let raw = profile.raw_data();
let mut head = vec![];
match profile.filters().as_slice() {
[] => head.extend(raw.iter().take(20)),
[hayro_syntax::Filter::FlateDecode] => {
let _ = flate2::read::ZlibDecoder::new(&*raw).take(20).read_to_end(&mut head); // a damaged profile reads as not Lab
}
_ => return false,
}
head.get(16..20) == Some(b"Lab ")
}
/// An alternate-space colour (the tint transform's output) as a VectorCraft colour.
fn alt_color(alt: Alt, v: &[f32]) -> Option<Color> {
let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite());
+24 -5
View File
@@ -248,7 +248,7 @@ struct Walker<'w, 'p> {
/// The groups that are off for the interpreter (none once [`all_on`] turned them on).
off: HashSet<ObjectIdentifier>,
xref: &'p hayro_syntax::xref::XRef,
fonts: &'w mut HashMap<u128, String>,
fonts: &'w mut FontNames,
visible: Vec<bool>,
ops: usize,
max_ops: usize,
@@ -296,7 +296,7 @@ impl Walker<'_, '_> {
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
&& let Some(name) = f.get::<Name<'_>>(b"BaseFont")
{
self.fonts.entry(f.cache_key()).or_insert_with(|| String::from_utf8_lossy(name.as_ref()).into_owned());
self.fonts.entry(f.cache_key()).or_insert_with(|| (String::from_utf8_lossy(name.as_ref()).into_owned(), embeds_glyphs(&f)));
}
}
}
@@ -372,13 +372,32 @@ impl Walker<'_, '_> {
}
}
/// The fonts of a document by cache key: base font name, and whether the file carries the glyphs
/// ([`embeds_glyphs`]).
pub(crate) type FontNames = HashMap<u128, (String, bool)>;
/// Whether font `f` carries its own glyphs: a font program in its descriptor (a composite font's in
/// its descendant's), or a Type 3 font's glyph procedures. A font that doesn't is drawn with a
/// stand-in, whose glyphs say nothing about which installed face the document means.
fn embeds_glyphs(f: &Dict<'_>) -> bool {
if f.get::<Name<'_>>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") {
return true;
}
// A font program is a stream, so always an indirect object.
let program = |d: &Dict<'_>| {
d.get::<Dict<'_>>(b"FontDescriptor").is_some_and(|fd| [&b"FontFile"[..], b"FontFile2", b"FontFile3"].iter().any(|k| fd.get_ref(*k).is_some()))
};
program(f) || f.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next()).is_some_and(|d| program(&d))
}
/// Walk `page` as the interpreter will: `all_on` when [`all_on`] turned every group on. Fonts
/// found are noted in `fonts` (their cache key → base font name).
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut HashMap<u128, String>) -> Scan {
/// found are noted in `fonts` (their cache key → base font name, and whether the file carries its
/// glyphs).
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut FontNames) -> Scan {
walk_page(page, ocgs, all_on, fonts).out
}
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap<u128, String>) -> Walker<'w, 'p> {
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> {
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false };
w.walk(page.typed_operations(), page.resources(), 0);
+30
View File
@@ -215,3 +215,33 @@ fn colour_spaces_are_read_from_the_interpreters_debug_form() {
assert_eq!((space, comps), ("ColorSpace(DeviceCmyk)", vec![0.101960786, 0.2, 0.3, 0.4]));
assert_eq!(crate::import_color::split_color("Color { color_space: X, components: [NaN], opacity: 1.0 }"), None);
}
/// Spot colours defined in Lab can have an ICC Lab profile as their alternate space, `[/ICCBased
/// profile]` with 3 components: the tint transform gives Lab values, which are not RGB (#1032). The
/// profile is told by its `Alternate` (`[/Lab …]`), else by its header's data colour space.
#[test]
fn spot_colours_with_an_icc_lab_alternate_keep_their_lab_values() {
let f = first_extra(1);
// A profile's first 20 bytes: size, CMM, version, class, then the data colour space.
let profile = |head: &str, alternate: &str| format!("<< /N 3 {alternate} /Length {} >>\nstream\n{head}\nendstream", head.len());
let lab_header = profile("0000none0000spacLab XYZ ", "");
let lab_alternate = profile("0000none0000spacXYZ XYZ ", "/Alternate [/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-128 127 -128 127] >>]");
let rgb = profile("0000none0000mntrRGB XYZ ", "");
let tint = |c1: &str| format!("<< /FunctionType 2 /Domain [0 1] /C0 [100 0 0] /C1 [{c1}] /N 1 >>");
let spaces = format!(
"/CS0 [/Separation /Plum [/ICCBased {f} 0 R] {}] /CS1 [/Separation /Sky [/ICCBased {} 0 R] {}] /CS2 [/Separation /Leafy [/ICCBased {} 0 R] << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0.5 0.9 0.1] /N 1 >>]",
tint("32.9412 45 -18"),
f + 1,
tint("51 -26 -45"),
f + 2
);
let page = page_with(&spaces, &[("CS0", "1"), ("CS1", "0.5"), ("CS2", "1")]);
let d = import(&pdf_with(&[page], &[&lab_header, &lab_alternate, &rgb], None)).unwrap();
let f = fills(&d);
assert_eq!(solid(&f[0]).1, Some("Plum"));
assert!(near(d.swatch("Plum").unwrap().paint.color().unwrap(), Color::lab(32.941, 45.0, -18.0)), "{:?}", d.swatch("Plum"));
assert_eq!((solid(&f[1]).1, solid(&f[1]).2), (Some("Sky"), 0.5));
assert!(near(d.swatch("Sky").unwrap().paint.color().unwrap(), Color::lab(51.0, -26.0, -45.0)), "{:?}", d.swatch("Sky"));
// An RGB profile stays RGB.
assert!(near(d.swatch("Leafy").unwrap().paint.color().unwrap(), Color::rgb(0.5, 0.9, 0.1)), "{:?}", d.swatch("Leafy"));
}
+13 -9
View File
@@ -109,15 +109,19 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
// Manual kerning replaces the font's pair kerning.
p.push(("font-kerning", "none".into()));
}
if !st.features.is_empty() {
let v: Vec<String> = st
.features
.iter()
.map(|t| match t.strip_prefix('-') {
Some(off) => format!("\"{off}\" 0"),
None => format!("\"{t}\" 1"),
})
.collect();
let mut v: Vec<String> = st
.features
.iter()
.map(|t| match t.strip_prefix('-') {
Some(off) => format!("\"{off}\" 0"),
None => format!("\"{t}\" 1"),
})
.collect();
// Proportional Metrics: `palt` (SVG writes vertical type as outlines).
if st.proportional_metrics {
v.push("\"palt\" 1".into());
}
if !v.is_empty() {
p.push(("font-feature-settings", v.join(", ")));
}
match (st.underline, st.strikethrough) {
+11 -10
View File
@@ -47,8 +47,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))?
};
let size = tree.size();
let (kx, ky) = units.k;
let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky);
let k = units.k;
let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k);
doc.units = units.unit;
let mut im = Importer {
doc,
@@ -68,7 +68,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
data: found.data,
sublayers: found.sublayers,
sublayer_groups: HashSet::new(),
screen: (kx * ky).sqrt(),
screen: k,
};
if im.files.nested_text() {
im.warn("text inside an SVG image isn't imported".into());
@@ -76,7 +76,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
let mut top = tree.root();
let mut base = Affine::scale_non_uniform(kx, ky) * aff(top.transform());
let mut base = Affine::scale(k) * aff(top.transform());
if let Some(g) = im.wrapper(top) {
base *= aff(g.transform());
top = g;
@@ -212,12 +212,14 @@ pub(super) const DEFAULT_FONT_SIZE: f64 = 12.0;
/// A pixel (and a unitless user unit) is a point, as we export, and absolute lengths keep their
/// physical size at 72 pt per inch: `font-size="12pt"` is 12 pt, `1in` is 72 pt. A root size in
/// absolute units (`width="210mm"`) is that physical size, and its user units are CSS pixels of it
/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions.
/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions. With one
/// side absolute and the other not (`width="96pt" height="96"`), both are: the unitless side is
/// CSS pixels too, so the drawing isn't stretched (#1010).
struct RootUnits {
/// Pixels per inch usvg (and the text pass) convert absolute lengths at.
dpi: f64,
/// Points per user unit of the root along x and y.
k: (f64, f64),
/// Points per user unit of the root, along both axes.
k: f64,
/// Document units: those of the root `width` (pixels when it has none).
unit: Unit,
}
@@ -228,8 +230,7 @@ impl RootUnits {
use svgtypes::LengthUnit as U;
let physical = |u: Option<U>| matches!(u, Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc));
let (w, h) = (unit("width"), unit("height"));
let k = |u| if physical(u) { PT_PER_IN / CSS_PX_PER_IN } else { 1.0 };
let dpi = if physical(w) || physical(h) { CSS_PX_PER_IN } else { PT_PER_IN };
let (dpi, k) = if physical(w) || physical(h) { (CSS_PX_PER_IN, PT_PER_IN / CSS_PX_PER_IN) } else { (PT_PER_IN, 1.0) };
let unit = match w {
Some(U::Mm) => Unit::Millimeters,
Some(U::Cm) => Unit::Centimeters,
@@ -238,7 +239,7 @@ impl RootUnits {
Some(U::Pc) => Unit::Picas,
_ => Unit::Pixels,
};
Self { dpi, k: (k(w), k(h)), unit }
Self { dpi, k, unit }
}
}
+3 -2
View File
@@ -41,8 +41,9 @@
//! * One CSS pixel (user unit) is one point, as on export, and absolute lengths (`in`, `cm`, `mm`,
//! `pt`, `pc`) keep their physical size (72 pt per inch): `font-size="12pt"` is 12 pt. A root
//! `width`/`height` in absolute units keeps its physical size, so a 210 mm SVG opens on a 210 mm
//! artboard, its user units being CSS pixels of it (96 per inch). The document's units follow the
//! unit of the root `width` (pixels when it has none).
//! artboard, its user units being CSS pixels of it (96 per inch), the other side's too when only
//! one is absolute. The document's units follow the unit of the root `width` (pixels when it has
//! none).
//! * `<mask>` imports as a luminance opacity mask; a mask we exported keeps its options and art
//! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and
//! not inverted). Nested clip paths (`<clipPath clip-path>`) are clip groups in clip groups.
+25
View File
@@ -811,6 +811,31 @@ fn import_absolute_units_keep_their_physical_size() {
}
}
/// #1010: a root with one absolute side and one unitless or px side opens as if both were
/// absolute (CSS pixels at 96 per inch), so nothing is stretched and the viewport keeps its size.
#[test]
fn import_mixed_root_units_scale_both_sides_alike() {
let rect = |d: &Document| art(d)[0].geometric_bounds().unwrap();
for (size, board) in [
(r#"width="96pt" height="96""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
(r#"width="96pt" height="96px""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
(r#"width="96pt" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
(r#"width="128" height="72pt""#, Rect::new(0.0, 0.0, 96.0, 72.0)),
] {
let svg =
|extra: &str| format!(r##"<svg xmlns="http://www.w3.org/2000/svg" {size} {extra}><rect width="32" height="32" fill="#f00"/></svg>"##);
let d = import(&svg("")).unwrap();
assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect);
assert!(close_rect(rect(&d), Rect::new(0.0, 0.0, 24.0, 24.0), 1e-6), "{size}: {:?}", rect(&d));
// With a viewBox: fitted uniformly (xMidYMid meet), unless preserveAspectRatio is none.
let d = import(&svg(r#"viewBox="0 0 32 32""#)).unwrap();
assert!(close_rect(d.artboards[0].rect, board, 1e-6), "{size}: {:?}", d.artboards[0].rect);
assert!(close_rect(rect(&d), Rect::new(12.0, 0.0, 84.0, 72.0), 1e-6), "{size} viewBox: {:?}", rect(&d));
let d = import(&svg(r#"viewBox="0 0 32 32" preserveAspectRatio="none""#)).unwrap();
assert!(close_rect(rect(&d), board, 1e-6), "{size} none: {:?}", rect(&d));
}
}
#[test]
fn full_document_roundtrip_is_stable() {
// export → import → export produces identical SVG.
+14
View File
@@ -105,6 +105,20 @@ fn rich_text_writes_spacing_shift_and_scale() {
assert_eq!(attr(tags[1], "y"), attr(tags[0], "y"));
}
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
#[test]
fn proportional_metrics_write_palt() {
let proportional = CharStyle { proportional_metrics: true, ..style(12.0) };
let both = CharStyle { proportional_metrics: true, features: vec!["dlig".into()], ..style(12.0) };
let d = area_doc(vec![run("雅楽 ", style(12.0)), run("「あ」 ", proportional), run("ab", both)], 280.0, Justify::Left);
let svg = export(&d, &ExportOptions::default());
let tags = tspans(&svg);
assert_eq!(tags.len(), 3, "{svg}");
assert!(!tags[0].contains("palt"), "{}", tags[0]);
assert!(tags[1].contains("font-feature-settings:&quot;palt&quot; 1"), "{}", tags[1]);
assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]);
}
#[test]
fn fewer_tspans_writes_one_per_line() {
let bold = CharStyle { font_style: "Bold".into(), ..style(12.0) };
+17
View File
@@ -160,6 +160,23 @@ impl OtFeatures {
// and digits itself (they would lie upside down, and tate-chu-yoko break).
v.push(f(b"vert", true));
}
if self.proportional(st) {
v.push(f(b"palt", true));
}
v
}
/// Does text in style `st` take proportional widths (`palt`)? Proportional Metrics in
/// horizontal type; vertical type (`vpal`) needs vertical shaping, so it sets as before.
pub(crate) fn proportional(&self, st: &CharStyle) -> bool {
st.proportional_metrics && !self.vertical
}
/// The harfrust features for text in style `st` with full-width glyphs on their full widths:
/// [`Self::resolve`] without `palt`.
pub(crate) fn resolve_fixed_width(&self, st: &CharStyle) -> Vec<Feature> {
let mut v = self.resolve(st);
v.retain(|x| x.tag != Tag::new(b"palt"));
v
}
}
+12 -4
View File
@@ -200,15 +200,21 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
}
/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which
/// it fits, searched by bisection over the first run's size in steps of 0.1 pt (so its scaled size
/// it fits, searched by bisection over the first run's document size in steps of 0.1 pt (so its scaled size
/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there,
/// still overflowing. None (keep the full-size layout) when the text has no usable size.
fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option<TextLayout> {
let size = t.runs.first().map(|r| r.style.size).filter(|s| s.is_finite() && *s >= 0.1)?;
// Exact size edits after object scaling may store a local size below 0.1. Fit in the
// same document points as the Character controls, independent of that representation.
let points = t.style_scale()?.points;
let size = t.runs.first().map(|r| r.style.size * points).filter(|s| s.is_finite() && *s >= 0.1)?;
if !(min.is_finite() && min < 1.0) {
return None;
}
let tenths = size * 10.0;
if !tenths.is_finite() {
return None;
}
// Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow
// unless it is below the full size; `lo` is the smallest allowed.
let mut lo = (tenths * min).ceil().max(1.0);
@@ -234,7 +240,7 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) ->
}
// Invariant: everything at `hi` or above overflows; `lo` fits or is the floor.
while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES {
let mid = ((lo + hi) * 0.5).floor();
let mid = (lo + (hi - lo) * 0.5).floor();
let l = at(mid);
passes += 1;
if l.overflow {
@@ -381,7 +387,8 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
/// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its
/// advance an em, give or take a tenth), the space that mojikumi can take off.
fn punct_half(g: &SGlyph, kind: Punct) -> Option<f64> {
if punct(g.ch)? != kind || g.tcy.is_some() {
// A glyph Proportional Metrics re-spaced has no empty half left to take off (#966).
if punct(g.ch)? != kind || g.tcy.is_some() || g.proportional {
return None;
}
let em = g.face.units_per_em();
@@ -1170,6 +1177,7 @@ mod wrapping_tests {
tcy: None,
inline: None,
lead: 0.0,
proportional: false,
level: unicode_bidi::Level::ltr(),
})
.collect()
+44 -24
View File
@@ -49,6 +49,9 @@ pub(crate) struct SGlyph {
/// Japanese composition: the space taken off before the glyph (an opening bracket after
/// another, see [`crate::layout`]); the glyph is drawn that much earlier on the line.
pub lead: f64,
/// Proportional Metrics changed the glyph's advance (`palt`): it has its proportional width,
/// and Japanese composition takes no more space off it (#966).
pub proportional: bool,
/// Resolved Unicode bidi embedding level (logical source order).
pub level: Level,
/// An inline graphic ([`vectorcraft_doc::TextRun::inline`]) standing in for a glyph.
@@ -117,6 +120,7 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
h.len = 0;
h.ch = '-';
h.inline = None;
h.proportional = false;
h
}
@@ -422,6 +426,7 @@ fn inline_glyph(face: &Arc<FontFace>, st: &CharStyle, art: &InlineArt, run: usiz
ch: text.get(range.clone()).and_then(|s| s.chars().next()).unwrap_or(INLINE_CHAR),
tcy: None,
lead: 0.0,
proportional: false,
// U+FFFC is a bidi neutral (ON): its level, resolved from its neighbours, places it in
// RTL text like any other glyph once the line is reordered.
level,
@@ -470,34 +475,48 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
// Proportional Metrics: which glyphs `palt` gave another advance than their full width.
let mut proportional = vec![];
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
if upper {
for u in c.to_uppercase() {
buf.add(u, cl);
let shape = |feats: &[Feature]| {
let mut buf = UnicodeBuffer::new();
for (i, c) in text_seg.char_indices() {
let cl = (range.start + i) as u32;
if upper {
for u in c.to_uppercase() {
buf.add(u, cl);
}
} else {
buf.add(c, cl);
}
} else {
buf.add(c, cl);
}
}
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
buf.guess_segment_properties();
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
// glyphs for it. This retains context at real style/font segmentation boundaries without
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
if let Some(pre) = text.get(context.start..range.start) {
buf.set_pre_context(pre);
}
if let Some(post) = text.get(range.end..context.end) {
buf.set_post_context(post);
}
let feats: Vec<Feature> = feats.resolve(st);
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
buf.guess_segment_properties();
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
// glyphs for it. This retains context at real style/font segmentation boundaries without
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
if let Some(pre) = text.get(context.start..range.start) {
buf.set_pre_context(pre);
}
if let Some(post) = text.get(range.end..context.end) {
buf.set_post_context(post);
}
let gb = shaper.shape(buf, ShapeOptions::new().features(feats));
gb.glyph_infos()
.iter()
.zip(gb.glyph_positions())
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset))
.collect::<Vec<_>>()
};
raw = shape(&feats.resolve(st));
if feats.proportional(st) {
// `palt` is positioning only: the same glyphs set on their full widths tell which
// advances it changed (kerning and the rest change both alike).
let full = shape(&feats.resolve_fixed_width(st));
if full.len() == raw.len() && full.iter().zip(&raw).all(|(a, b)| a.0 == b.0) {
proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect();
}
}
});
if shaped.is_none() {
@@ -553,6 +572,7 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
ch,
tcy: None,
lead: 0.0,
proportional: proportional.get(gi).copied().unwrap_or(false),
level: *level,
inline: None,
});
+13
View File
@@ -54,6 +54,19 @@ fn shrink_text_makes_overflowing_text_fit() {
assert_eq!(layout(db(), &t).fit_scale, l.fit_scale);
}
#[test]
fn scaled_area_shrink_accepts_small_local_sizes_and_uses_document_tenths() {
// A 14 pt story represented in a scaled local coordinate system. Its local size is
// below 0.1, as happens when setting an exact size after enlarging a text group.
let mut t = area(COPY, 0.014, Rect::new(0.0, 0.0, 0.2, 0.09), shrink(30.0));
t.xf = Affine::scale(1000.0);
assert!(at_size(&t, 0.014).overflow);
let l = layout(db(), &t);
assert!(l.fit_scale < 1.0 && l.fit_scale >= 0.3, "{}", l.fit_scale);
let document_size = 14.0 * l.fit_scale;
assert!((document_size * 10.0 - (document_size * 10.0).round()).abs() < 1e-6);
}
#[test]
fn shrink_text_respects_its_minimum() {
let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0));
+45
View File
@@ -749,3 +749,48 @@ fn layout_10k_area_text_is_fast() {
assert!(per < budget, "{justify:?} {composer:?}: {per} ms");
}
}
/// Proportional Metrics (#966): in horizontal type full-width glyphs take the font's proportional
/// widths (`palt`), and Japanese composition takes nothing more off the punctuation `palt`
/// re-spaced (the flush bracket at a paragraph's start, consecutive punctuation, half width at a
/// line's end); punctuation it leaves full width is trimmed as before. Vertical type sets as
/// before. Needs craft-fonts' Shippori Mincho, whose `palt` covers kana and most brackets but not
/// 〘〙 (skipped without it).
#[test]
fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() {
use vectorcraft_doc::Mojikumi;
if db().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
}
let st = |on: bool| CharStyle { size: 20.0, font_family: "Shippori Mincho".into(), proportional_metrics: on, ..CharStyle::default() };
// Right-aligned in a frame ten ems wide, so a line's end trim moves the line.
let lay = |text: &str, on: bool, m: Mojikumi, vertical: bool| {
let mut t = area(text, st(on), Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Right);
t.vertical = vertical;
t.para.mojikumi = m;
layout(db(), &t)
};
let adv = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
let x0 = |l: &TextLayout| l.glyphs.iter().map(|g| g.outline.bounding_box().x0).collect::<Vec<_>>();
let near = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01);
let off = adv(&lay("「あ漢」", false, Mojikumi::None, false));
let on = adv(&lay("「あ漢」", true, Mojikumi::None, false));
assert!(off.iter().all(|a| (a - 20.0).abs() < 0.01), "{off:?}");
assert!(on[0] < 15.0 && on[1] < 19.0 && on[3] < 15.0, "kana and brackets narrower: {on:?}");
assert!((on[2] - 20.0).abs() < 0.01, "the kanji keeps its em: {on:?}");
// Re-spaced punctuation: Line-end Punctuation Half Width changes nothing, at the paragraph's
// start (「), between punctuation (」、) or at the line's end (」).
for text in ["「あ」、漢", "漢「あ」"] {
let (half, none) = (lay(text, true, Mojikumi::LineEndHalf, false), lay(text, true, Mojikumi::None, false));
assert!(near(&adv(&half), &adv(&none)), "{text}: {:?} {:?}", adv(&half), adv(&none));
assert!(near(&x0(&half), &x0(&none)), "{text}: {:?} {:?}", x0(&half), x0(&none));
}
// Punctuation `palt` leaves full width: flush at the paragraph's start, half width at the end.
let (half, none) = (lay("〘あ〙", true, Mojikumi::LineEndHalf, false), lay("〘あ〙", true, Mojikumi::None, false));
assert!((none.glyphs[0].advance - 20.0).abs() < 0.01 && (half.glyphs[0].advance - 10.0).abs() < 0.01, "{:?}", adv(&half));
let moved = x0(&half)[1] - x0(&none)[1];
assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}");
// Vertical type sets as before.
assert!(near(&adv(&lay("「あ漢」", true, Mojikumi::None, true)), &adv(&lay("「あ漢」", false, Mojikumi::None, true))));
}
+7 -1
View File
@@ -372,7 +372,13 @@ impl Targets {
/// tools): they pull a point into line where they run.
fn with_rulers(mut self, cx: &ToolContext) -> Self {
if cx.guides {
self.rulers = cx.doc.guides.iter().map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }).collect();
self.rulers = cx
.doc
.guides
.iter()
.filter(|g| cx.doc.guide_shown(g))
.map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) })
.collect();
}
self
}
+18
View File
@@ -425,6 +425,11 @@ pub enum Cursor {
ResizeNwSe,
ResizeNeSw,
Rotate,
/// The rotate cursor beside a bounding box, bowed toward the pointer's side of the box: off
/// its centre in direction `0..8` (0 straight up, then clockwise in 45° steps). Its arc wraps
/// the corner it is at, so at the top right it opens toward the bottom left. Make it with
/// [`Cursor::rotate_about`].
RotateToward(u8),
/// Over a Live Corners widget (drag to round the corners).
CornerRadius,
Pen,
@@ -476,6 +481,18 @@ pub enum Cursor {
}
impl Cursor {
/// The rotate cursor for the pointer at `p` turning art about `center`: [`Cursor::RotateToward`]
/// the pointer's side of it, or the plain [`Cursor::Rotate`] on the centre itself.
pub fn rotate_about(center: Point, p: Point) -> Self {
let d = p - center;
if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 {
return Cursor::Rotate;
}
// y points down the page: atan2(x, -y) is 0 straight up and grows clockwise.
let octant = (d.x.atan2(-d.y) / std::f64::consts::FRAC_PI_4).round() as i64;
Cursor::RotateToward(octant.rem_euclid(8) as u8)
}
/// General › Use Precise Cursors: the drawing tools' pointers (the Pen's in every state, the
/// Eyedropper's, the Slice and Blend tools') become a plain crosshair at the hotspot.
pub fn precise(self) -> Self {
@@ -485,6 +502,7 @@ impl Cursor {
| Cursor::PenDelete
| Cursor::PenClose
| Cursor::PenContinue
| Cursor::PenJoin
| Cursor::PenConvert
| Cursor::Eyedropper
| Cursor::Slice
+3 -2
View File
@@ -5,7 +5,8 @@
//! 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.
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden
//! or locked layer, can't be picked.
use serde_json::json;
use vectorcraft_geom::Point;
@@ -24,7 +25,7 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option<usize> {
.guides
.iter()
.enumerate()
.filter(|(_, g)| cx.doc.guide_passes(g, p, tol))
.filter(|(_, g)| cx.doc.guide_editable(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)))
+20 -2
View File
@@ -444,7 +444,7 @@ impl Tool for SelectionTool {
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
match self.state {
State::Rotating { .. } => return Cursor::Rotate,
State::Rotating { center, .. } => return Cursor::rotate_about(center, p),
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
State::Moving { began: true, .. } => return Cursor::Arrow,
State::Corner(_) => return Cursor::CornerRadius,
@@ -468,7 +468,7 @@ impl Tool for SelectionTool {
{
match box_hit(cx, &bx, p) {
Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle),
Some(BoxHit::Rotate) => return Cursor::Rotate,
Some(BoxHit::Rotate) => return Cursor::rotate_about(bx.center(), p),
None => {}
}
}
@@ -588,6 +588,24 @@ mod tests {
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 160.0, 150.0)).is_empty());
}
/// Just outside each corner of the box the rotate cursor bows toward that corner, its arc
/// opening toward the box: at the top right it opens to the bottom left.
#[test]
fn the_rotate_cursor_wraps_the_corner_it_is_at() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let t = SelectionTool::default();
// The box runs from (100, 100) to (200, 200); 10 pt out from each corner, diagonally.
for ((x, y), octant) in [((93.0, 93.0), 7), ((207.0, 93.0), 1), ((207.0, 207.0), 3), ((93.0, 207.0), 5)] {
assert_eq!(t.cursor(&cx, Point::new(x, y), Mods::default()), Cursor::RotateToward(octant), "at ({x}, {y})");
}
assert_eq!(Cursor::rotate_about(Point::new(1.0, 1.0), Point::new(1.0, 1.0)), Cursor::Rotate, "on the centre");
assert_eq!(Cursor::rotate_about(Point::ORIGIN, Point::new(0.0, -5.0)), Cursor::RotateToward(0), "straight up");
}
#[test]
fn drag_moves_with_single_undo() {
let (d, id) = doc_with_rect();
+1 -1
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 && cx.doc.guide_passes(g, p, tol))
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_shown(g) && 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) {
+1 -1
View File
@@ -313,7 +313,7 @@ impl Tool for FreeTransformTool {
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let Some(r) = selection_bounds(cx) else { return Cursor::Arrow };
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
Some(Op::Rotate) => Cursor::Rotate,
Some(Op::Rotate) => Cursor::rotate_about(r.center(), p),
Some(Op::Move) => Cursor::Move,
Some(Op::Scale(h) | Op::Shear(h)) => match h {
Handle::Top | Handle::Bottom => Cursor::ResizeV,
+3 -2
View File
@@ -378,7 +378,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Selected guides in the selection colour.
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() {
// Guides on a hidden layer hide with it.
for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) {
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) {
@@ -487,7 +488,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
Cursor::ResizeV => C::ResizeVertical,
Cursor::ResizeNwSe => C::ResizeNwSe,
Cursor::ResizeNeSw => C::ResizeNeSw,
Cursor::Rotate => C::Alias,
Cursor::Rotate | Cursor::RotateToward(_) => C::Alias,
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue | Cursor::PenJoin | Cursor::PenConvert => {
C::Crosshair
}
+12 -6
View File
@@ -113,17 +113,21 @@ fn double_arrow(p: &mut Ink, o: Pos2, dir: egui::Vec2) {
}
/// A curved double arrow over the hotspot: an arc with an arrowhead at each end, pointing on along
/// the arc (#701).
fn rotate(p: &mut Ink, o: Pos2) {
/// the arc (#701). The arc bows up, or toward `octant` (0 up, then clockwise in 45° steps): beside a
/// bounding box it wraps the corner the pointer is at.
fn rotate(p: &mut Ink, o: Pos2, octant: u8) {
const R: f32 = 8.0;
let (a0, a1) = (std::f32::consts::PI * 0.12, std::f32::consts::PI * 0.88);
let at = |a: f32| o + vec2(a.cos() * R, -a.sin() * R);
// Clockwise on screen (y down) by whole octants.
let (s, c) = (f32::from(octant % 8) * std::f32::consts::FRAC_PI_4).sin_cos();
let turn = |v: Vec2| vec2(v.x * c - v.y * s, v.x * s + v.y * c);
let at = |a: f32| o + turn(vec2(a.cos() * R, -a.sin() * R));
let pts: Vec<Pos2> = (0..=12).map(|i| at(a0 + (a1 - a0) * i as f32 / 12.0)).collect();
p.add(Shape::line(pts.clone(), Stroke::new(3.2, HALO)));
p.add(Shape::line(pts, Stroke::new(1.3, INK)));
// Each head points away from the arc, along its tangent there.
for (a, away) in [(a0, -1.0f32), (a1, 1.0)] {
let t = vec2(-a.sin(), -a.cos()) * away;
let t = turn(vec2(-a.sin(), -a.cos()) * away);
let n = vec2(-t.y, t.x);
let (end, tip) = (at(a) - t * 1.5, at(a) + t * 4.5);
poly(p, vec![tip, end + n * 1.8, end - n * 1.8], INK, INK);
@@ -269,7 +273,8 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
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::Rotate => rotate(ink, p, 0),
Cursor::RotateToward(octant) => rotate(ink, p, octant),
Cursor::CornerRadius => corner_radius(ink, p),
Cursor::Pen => pen(ink, p, "*"),
Cursor::PenAdd => pen(ink, p, "+"),
@@ -432,7 +437,7 @@ mod tests {
use crate::VectorcraftApp;
/// Every cursor with a glyph.
const GLYPHS: [Cursor; 33] = [
const GLYPHS: [Cursor; 34] = [
Cursor::Arrow,
Cursor::ArrowHollow,
Cursor::Move,
@@ -442,6 +447,7 @@ mod tests {
Cursor::ResizeNwSe,
Cursor::ResizeNeSw,
Cursor::Rotate,
Cursor::RotateToward(1),
Cursor::CornerRadius,
Cursor::Pen,
Cursor::PenAdd,
+73 -11
View File
@@ -1,7 +1,8 @@
//! The Color Picker (double-click a Fill/Stroke proxy, or `ui.colorPicker`): a colour field with a
//! channel slider for H, S, B, R, G or B; HSB, RGB, Lab and CMYK fields and hex; Only Web Colors;
//! the out-of-gamut and out-of-web warnings (click to correct); New/Original chips; and a Color
//! Swatches list. OK applies the colour to its proxy through `paint.setFill` / `paint.setStroke`.
//! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one
//! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back.
//!
//! Fields agents set with `ui.dialog.set`: `hex` (`"00FF00"`) or `color` (any colour the paint
//! commands take), `channel` (`hue`, `saturation`, `brightness`, `red`, `green`, `blue`), `webOnly`
@@ -15,6 +16,7 @@ use vectorcraft_doc::ColorMode;
use vectorcraft_engine::cmd::color_value;
use super::DialogSpec;
use super::form::PREVIEWED;
use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe};
use crate::panels::{c32, color_json};
use crate::state::Dialog;
@@ -23,8 +25,15 @@ use crate::{VectorcraftApp, widgets};
pub(super) const KIND: &str = "colorPicker";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Color Picker").into(), body, confirm, min_width: 600.0, max_width: Some(640.0), ..DialogSpec::FORM };
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Color Picker").into(),
body,
confirm,
preview: true,
min_width: 600.0,
max_width: Some(640.0),
..DialogSpec::FORM
};
/// Side of the colour field (and height of the channel slider).
const FIELD: f32 = 240.0;
@@ -242,9 +251,39 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
d.fields.insert("swatches".into(), json!(!swatches));
}
store(d, &pick.unwrap_or(p).snapped(d.bool("webOnly")));
preview(app, d);
false
}
/// Show the picked colour on the selected art while the picker is open: each new colour replaces
/// the last preview. Nothing previews until the colour first changes, nor without a selection
/// (the colour then only becomes the default paint, on OK).
fn preview(app: &mut VectorcraftApp, d: &mut Dialog) {
let (cmd, params) = paint_command(app, d);
let first = !app.session.in_interaction();
let unchanged = d.fields.get(PREVIEWED) == Some(&params) || first && d.fields.get("original").and_then(color_value) == Some(picked(d).color);
let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty());
if unchanged || !selected {
return;
}
let _ = app.session.begin_interaction(if d.bool("stroke") { "Stroke Color" } else { "Fill Color" });
if let Err(e) = app.session.preview(cmd, &params) {
app.status(e.to_string());
}
d.fields.insert(PREVIEWED.into(), params);
}
/// The command that applies the picked colour to the proxy, and its params (as CMYK in a CMYK
/// document).
fn paint_command(app: &VectorcraftApp, d: &Dialog) -> (&'static str, Value) {
let mut c = picked(d).color;
if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) {
let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent);
c = Color::cmyk(cy, m, y, k);
}
(if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": color_json(&c) }))
}
/// The New (top) and Original (bottom) chips. Returns true when Original is clicked.
fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool {
let t = Tokens::get(ui.ctx());
@@ -363,15 +402,10 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color) -> Option<Col
chosen
}
/// OK: apply the colour to the proxy (as CMYK in a CMYK document).
/// OK: apply the colour to the proxy, keeping the live preview as one undo step.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut c = picked(d).color;
if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) {
let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent);
c = Color::cmyk(cy, m, y, k);
}
let cmd = if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" };
super::run_and_close(app, cmd, json!({ "color": color_json(&c) }))
let (cmd, params) = paint_command(app, d);
super::form::commit_preview(app, cmd, params)
}
#[cfg(test)]
@@ -453,6 +487,34 @@ mod tests {
assert_eq!(gamut_fix(&mut app, &ctx, "picker-gamut", &Color::rgb8(128, 128, 128)), None);
}
/// The selected art takes each colour as it is picked; OK keeps the last as one undo step and
/// Cancel puts the art back.
#[test]
fn the_selection_shows_the_colour_live() {
let mut app = app();
let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
let before = undo(&app);
app.run("ui.colorPicker", json!({})).unwrap();
frame(&mut app);
assert!(!app.session.in_interaction(), "nothing previews before the colour changes");
for hex in ["ff0000", "00ff00"] {
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex));
frame(&mut app);
assert_eq!(fill_hex(&app), format!("#{hex}"), "live, before OK");
}
super::super::confirm(&mut app).unwrap();
assert_eq!(fill_hex(&app), "#00ff00");
assert_eq!(undo(&app), before + 1, "one step");
// Cancel: back to the colour it had.
app.run("ui.colorPicker", json!({})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff"));
frame(&mut app);
assert_eq!(fill_hex(&app), "#0000ff");
super::super::cancel(&mut app);
assert_eq!(fill_hex(&app), "#00ff00");
assert_eq!(undo(&app), before + 1);
}
#[test]
fn open_set_hex_and_confirm_fills() {
let mut app = app();
+14 -2
View File
@@ -1,5 +1,6 @@
//! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column
//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed
//! whose panels pop out to the left, and left of it the floating sets docked as columns
//! ([`floating::docked_columns`]). The double arrow at the top of the dock collapses the tabbed
//! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column
//! then expands it again.
@@ -23,6 +24,12 @@ fn column_left_id() -> egui::Id {
egui::Id::new("dock-icon-column-left")
}
/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's
/// (egui temp memory, one frame old). Popped-out panels open left of it.
fn dock_left_id() -> egui::Id {
egui::Id::new("dock-left")
}
/// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while
/// one of its panels is popped out shows that panel's tab instead.
pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
@@ -148,7 +155,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
let column = column.response.rect;
let dock = group.map_or(column, |g| g.union(column));
// Floating sets docked as columns, left of the icon column.
let left = floating::docked_columns(app, ui).iter().map(|r| r.left()).fold(column.left(), f32::min);
ui.ctx().data_mut(|d| {
d.insert_temp(dock_left_id(), left);
d.insert_temp(column_left_id(), column.left());
d.insert_temp(floating::dock_rect_id(), dock);
d.insert_temp(floating::icons_rect_id(), column);
@@ -226,7 +236,9 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
} else {
screen.right()
};
let right = column - 6.0;
let left = ctx.data(|d| d.get_temp::<f32>(dock_left_id())).filter(|x| x.is_finite() && *x > screen.left() && *x <= column).unwrap_or(column);
let right = if app.ui.dock && app.ui.screen_mode < 3 { left } else { column };
let right = right - 6.0;
// The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one
// that keeps the flyout on screen.
let width = panel_width(id);
File diff suppressed because it is too large. Load diff
+3
View File
@@ -628,6 +628,7 @@
% of all commits % všech commitů
Lines +/− Řádky +/−
Drag to move Přetažením přesunete
Drag to move out of the set Přetažením přesunete mimo sadu
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 Panel Plovoucí panel
@@ -731,3 +732,5 @@ lighting Light Světlo
Spatter… Rozstřik…
Sprayed Strokes… Stříkané tahy…
Sumi-e… Sumi-e…
Vertical Guide Svislé vodítko
Horizontal Guide Vodorovné vodítko
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10 -1
View File
@@ -362,6 +362,9 @@
Position Panel Above Text Bedienfeld über dem Text positionieren
Clear Appearance Aussehen löschen
Clear Guides Hilfslinien löschen
Vertical Guide Vertikale Hilfslinie
Horizontal Guide Horizontale Hilfslinie
Move Guide to Layer Hilfslinie auf Ebene verschieben
Clear Overrides Abweichungen löschen
Clear Recent Files Liste der zuletzt geöffneten Dateien leeren
Click Klicken
@@ -1997,6 +2000,7 @@
Proof Colors Farbproof
Proof Setup Proof einrichten
Properties Eigenschaften
Proportional Metrics Proportionale Metrik
Proportional Oldstyle Proportionale Mediävalziffern
Pucker & Bloat… Zusammenziehen und aufblasen…
Pucker Tool Zusammenziehen-Werkzeug
@@ -3213,6 +3217,7 @@ axis Vertical Vertikal
axis Both Beide
theme Appearance Erscheinungsbild
Drag to move Zum Verschieben ziehen
Drag to move out of the set Ziehen, um aus dem Satz zu lösen
Drag to float as a toolbar Ziehen, um als eigene Werkzeugleiste abzulösen
Put back in the toolbar Zurück in die Werkzeugleiste setzen
Float Panel Bedienfeld ablösen
@@ -3656,6 +3661,7 @@ theme Appearance Erscheinungsbild
@msg the image's pixels don't match its size die Pixel des Bilds passen nicht zu seiner Größe
@msg the layer has no objects to clip by die Ebene hat keine Objekte, an denen beschnitten werden könnte
@msg the layer or group is locked die Ebene oder Gruppe ist gesperrt
@msg the guide is on a hidden or locked layer die Hilfslinie liegt auf einer ausgeblendeten oder gesperrten Ebene
@msg the target layer is locked or hidden die Zielebene ist gesperrt oder ausgeblendet
@msg the layer to flatten into is hidden die Ebene, auf die reduziert werden soll, ist ausgeblendet
@msg the object already has an opacity mask das Objekt hat bereits eine Deckkraftmaske
@@ -3681,7 +3687,7 @@ theme Appearance Erscheinungsbild
@msg the {tool} tool has no options das Werkzeug „{tool}“ hat keine Optionen
@msg there are no objects to release es gibt keine Objekte zum Zurückwandeln
@msg there are no printer settings to open here hier lassen sich keine Druckereinstellungen öffnen
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it diese PostScript-Datei kann nicht gelesen werden ({_1}): Speichern Sie sie in der App, mit der sie erstellt wurde, als PDF oder SVG
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it diese PostScript-Datei kann nicht gelesen werden ({why}): Speichern Sie sie in der App, mit der sie erstellt wurde, als PDF oder SVG
@msg this file's PostScript couldn't be read ({why}): {what} das PostScript dieser Datei konnte nicht gelesen werden ({why}): {what}
@msg this format holds one artboard: export several as PDF or with document.exportForScreens dieses Format fasst eine Zeichenfläche: Exportieren Sie mehrere als PDF oder mit 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 dies ist eine DWG-Zeichnung, die nicht gelesen werden kann: Speichern Sie sie in der CAD-App als ASCII-DXF und öffnen Sie diese Datei
@@ -3769,6 +3775,9 @@ theme Appearance Erscheinungsbild
@msg tool (a tool id) is required tool (eine Werkzeug-ID) ist erforderlich
@msg {tool} isn't in a toolbar group of several tools {tool} gehört zu keiner Werkzeuggruppe mit mehreren Werkzeugen
@msg onto must name a panel floating in another group onto muss ein Bedienfeld nennen, das in einer anderen Gruppe schwebt
@msg below and above must name a panel floating in another group below und above müssen ein Bedienfeld nennen, das in einer anderen Gruppe schwebt
@msg give only one of onto, below and above nur eines von onto, below und above angeben
@msg column must be true or a number from 1 column muss true oder eine Zahl ab 1 sein
@msg x and y must both be numbers x und y müssen beide Zahlen sein
@msg "{0}" is not a colour: write it as #rrggbb "{0}" ist keine Farbe: Schreiben Sie sie als #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead dies ist kein Logo aus flächigen Farben ({_1} % seiner einfarbigen Bereiche entsprechen wenigen flächigen Farben), daher würde der Modus „Flaches Logo“ seine Verläufe in Flecken zerlegen: Verwenden Sie stattdessen den Modus „Farbe“
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10 -1
View File
@@ -2051,6 +2051,7 @@
Proof Colors Colores de prueba
Proof Setup Ajuste de prueba
Properties Propiedades
Proportional Metrics Métrica proporcional
Proportional Oldstyle Estilo antiguo proporcional
Pucker & Bloat… Fruncir y engordar…
Pucker Tool Herramienta Fruncir
@@ -3317,6 +3318,7 @@ axis Horizontal Horizontal
axis Vertical Vertical
axis Both Ambos
Drag to move Arrastrar para mover
Drag to move out of the set Arrastrar para sacar del conjunto
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 Panel Hacer flotar el panel
@@ -3759,6 +3761,7 @@ axis Both Ambos
@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 guide is on a hidden or locked layer la guía está en una capa oculta o bloqueada
@msg the target layer is locked or hidden la capa de destino está bloqueada u oculta
@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
@@ -3784,7 +3787,7 @@ axis Both Ambos
@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 PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it no se puede leer este archivo PostScript ({why}): guárdalo como PDF o SVG en la aplicación que lo creó
@msg this file's PostScript couldn't be read ({why}): {what} no se pudo leer el PostScript de este archivo ({why}): {what}
@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
@@ -3873,6 +3876,9 @@ axis Both Ambos
@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 onto must name a panel floating in another group onto debe nombrar un panel que flote en otro grupo
@msg below and above must name a panel floating in another group below y above deben nombrar un panel que flote en otro grupo
@msg give only one of onto, below and above indica solo uno de onto, below y above
@msg column must be true or a number from 1 column debe ser true o un número desde 1
@msg x and y must both be numbers x e y deben ser números
@msg "{0}" is not a colour: write it as #rrggbb "{0}" no es un color: escríbelo como #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color
@@ -4093,3 +4099,6 @@ lighting Light Luz
Stroke Pressure Presión de pincelada
Right Diagonal Diagonal derecha
Left Diagonal Diagonal izquierda
Vertical Guide Guía vertical
Horizontal Guide Guía horizontal
Move Guide to Layer Mover guía a capa
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10 -1
View File
@@ -2048,6 +2048,7 @@
Proof Colors Couleurs d’épreuve
Proof Setup Format d’épreuve
Properties Propriétés
Proportional Metrics Métrique proportionnelle
Proportional Oldstyle Elzévirien proportionnel
Pucker & Bloat… Contracter et dilater…
Pucker Tool Outil Contraction
@@ -3314,6 +3315,7 @@ axis Horizontal Horizontal
axis Vertical Vertical
axis Both Les deux
Drag to move Faire glisser pour déplacer
Drag to move out of the set Faire glisser pour sortir de l’ensemble
Drag to float as a toolbar Faire glisser pour détacher en barre d’outils flottante
Put back in the toolbar Remettre dans la barre d’outils
Float Panel Rendre le panneau flottant
@@ -3756,6 +3758,7 @@ axis Both Les deux
@msg the image's pixels don't match its size les pixels de l’image ne correspondent pas à sa taille
@msg the layer has no objects to clip by le calque n’a aucun objet servant à l’écrêtage
@msg the layer or group is locked le calque ou le groupe est verrouillé
@msg the guide is on a hidden or locked layer le repère se trouve sur un calque masqué ou verrouillé
@msg the target layer is locked or hidden le calque cible est verrouillé ou masqué
@msg the layer to flatten into is hidden le calque de destination de l’aplatissement est masqué
@msg the object already has an opacity mask l’objet a déjà un masque d’opacité
@@ -3781,7 +3784,7 @@ axis Both Les deux
@msg the {tool} tool has no options l’outil {tool} n’a pas d’options
@msg there are no objects to release il n’y a aucun objet à libérer
@msg there are no printer settings to open here il n’y a pas de réglages d’imprimante à ouvrir ici
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({_1}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it impossible de lire ce fichier PostScript ({why}) : enregistrez-le au format PDF ou SVG dans l’application qui l’a créé
@msg this file's PostScript couldn't be read ({why}): {what} impossible de lire le PostScript de ce fichier ({why}) : {what}
@msg this format holds one artboard: export several as PDF or with document.exportForScreens ce format ne contient qu’un plan de travail : exportez-en plusieurs au format PDF ou avec 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 c’est un dessin DWG, qui ne peut pas être lu : enregistrez-le depuis l’application de CAO au format DXF ASCII et ouvrez ce fichier
@@ -3870,6 +3873,9 @@ axis Both Les deux
@msg tool (a tool id) is required tool (un identifiant d’outil) est obligatoire
@msg {tool} isn't in a toolbar group of several tools {tool} ne fait pas partie d’un groupe de plusieurs outils dans la barre d’outils
@msg onto must name a panel floating in another group onto doit désigner un panneau flottant dans un autre groupe
@msg below and above must name a panel floating in another group below et above doivent désigner un panneau flottant dans un autre groupe
@msg give only one of onto, below and above indiquez un seul de onto, below et above
@msg column must be true or a number from 1 column doit être true ou un nombre à partir de 1
@msg x and y must both be numbers x et y doivent tous deux être des nombres
@msg "{0}" is not a colour: write it as #rrggbb « {0} » n’est pas une couleur : écrivez-la sous la forme #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur
@@ -4103,3 +4109,6 @@ lighting Light Lumière
Stroke Pressure Pression des traits
Right Diagonal Diagonale droite
Left Diagonal Diagonale gauche
Vertical Guide Repère vertical
Horizontal Guide Repère horizontal
Move Guide to Layer Déplacer le repère vers le calque
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10 -1
View File
@@ -2051,6 +2051,7 @@
Proof Colors Colori di prova
Proof Setup Impostazione prova
Properties Proprietà
Proportional Metrics Metrica proporzionale
Proportional Oldstyle Proporzionali stile antico
Pucker & Bloat… Contrai e gonfia…
Pucker Tool Strumento Contrai
@@ -3317,6 +3318,7 @@ axis Horizontal Orizzontale
axis Vertical Verticale
axis Both Entrambi
Drag to move Trascina per spostare
Drag to move out of the set Trascina per spostare fuori dall’insieme
Drag to float as a toolbar Trascina per rendere mobile come barra degli strumenti
Put back in the toolbar Riporta nella barra degli strumenti
Float Panel Rendi mobile pannello
@@ -3759,6 +3761,7 @@ axis Both Entrambi
@msg the image's pixels don't match its size i pixel dell'immagine non corrispondono alle sue dimensioni
@msg the layer has no objects to clip by il livello non ha oggetti con cui ritagliare
@msg the layer or group is locked il livello o il gruppo è bloccato
@msg the guide is on a hidden or locked layer la guida si trova su un livello nascosto o bloccato
@msg the target layer is locked or hidden il livello di destinazione è bloccato o nascosto
@msg the layer to flatten into is hidden il livello in cui appiattire è nascosto
@msg the object already has an opacity mask l'oggetto ha già una maschera di opacità
@@ -3784,7 +3787,7 @@ axis Both Entrambi
@msg the {tool} tool has no options lo strumento {tool} non ha opzioni
@msg there are no objects to release non ci sono oggetti da rilasciare
@msg there are no printer settings to open here non ci sono impostazioni della stampante da aprire qui
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it impossibile leggere questo file PostScript ({_1}): salvalo come PDF o SVG nell'app che lo ha creato
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it impossibile leggere questo file PostScript ({why}): salvalo come PDF o SVG nell'app che lo ha creato
@msg this file's PostScript couldn't be read ({why}): {what} impossibile leggere il PostScript di questo file ({why}): {what}
@msg this format holds one artboard: export several as PDF or with document.exportForScreens questo formato contiene una sola tavola da disegno: esportane più di una come 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 questo è un disegno DWG, che non può essere letto: salvalo dall'app CAD come DXF ASCII e apri quel file
@@ -3873,6 +3876,9 @@ axis Both Entrambi
@msg tool (a tool id) is required tool (un id di strumento) è obbligatorio
@msg {tool} isn't in a toolbar group of several tools {tool} non fa parte di un gruppo di più strumenti nella barra degli strumenti
@msg onto must name a panel floating in another group onto deve indicare un pannello mobile in un altro gruppo
@msg below and above must name a panel floating in another group below e above devono indicare un pannello mobile in un altro gruppo
@msg give only one of onto, below and above indica solo uno tra onto, below e above
@msg column must be true or a number from 1 column deve essere true o un numero da 1
@msg x and y must both be numbers x e y devono essere entrambi numeri
@msg "{0}" is not a colour: write it as #rrggbb "{0}" non è un colore: scrivilo come #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore
@@ -4095,3 +4101,6 @@ lighting Light Luce
Stroke Pressure Pressione pennellata
Right Diagonal Diagonale destra
Left Diagonal Diagonale sinistra
Vertical Guide Guida verticale
Horizontal Guide Guida orizzontale
Move Guide to Layer Sposta guida nel livello
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10 -1
View File
@@ -37,6 +37,7 @@
Em Box Top/Right 仮想ボディの上 / 右
ICF Top/Right 平均字面の上 / 右
ICF Bottom/Left 平均字面の下 / 左
Proportional Metrics プロポーショナルメトリクス
Favorites お気に入り
Flip 反転
Japanese 日本語
@@ -3265,6 +3266,7 @@ axis Horizontal 水平方向
axis Vertical 垂直方向
axis Both 両方
Drag to move 移動するにはドラッグ
Drag to move out of the set ドラッグしてセットから出す
Drag to float as a toolbar ドラッグしてツールバーとして浮かせる
Put back in the toolbar ツールバーに戻す
Float Panel パネルをフローティング
@@ -3497,6 +3499,7 @@ theme Appearance 外観
@msg no selection bounds 選択範囲に境界がありません
@msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません
@msg the layer or group is locked レイヤーまたはグループがロックされています
@msg the guide is on a hidden or locked layer ガイドが非表示またはロックされたレイヤー上にあります
@msg the target layer is locked or hidden ターゲットレイヤーがロックされているか非表示です
@msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています
@msg there are no objects to release レイヤーに分配するオブジェクトがありません
@@ -3634,7 +3637,7 @@ theme Appearance 外観
@msg shadings of an unknown type were left out 種類が不明なシェーディングは除外されました
@msg shadings whose functions are of an unknown type are filled with their middle colour 関数の種類が不明なシェーディングは中間の色で塗りつぶされています
@msg the file's preview header points outside the file ファイルのプレビューヘッダーがファイルの範囲外を指しています
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({_1}): 作成したアプリで PDF または SVG として保存してください
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it この PostScript ファイルは読み込めません ({why}): 作成したアプリで PDF または SVG として保存してください
@msg this is not a PostScript file PostScript ファイルではありません
@msg a PostScript error ({name} in `{op}`) PostScript エラー (`{op}` での {name})
@msg a PostScript error ({name} in `{op}`), in {procs} {procs} 内の PostScript エラー (`{op}` での {name})
@@ -3796,6 +3799,9 @@ theme Appearance 外観
@msg give the path `id` and the width point `index` パスの `id` と幅ポイントの `index` を指定してください
@msg no such width point 指定された幅ポイントが見つかりません
@msg onto must name a panel floating in another group onto には、別のグループでフローティングしているパネルを指定してください
@msg below and above must name a panel floating in another group below と above には別のグループでフローティングしているパネルを指定してください
@msg give only one of onto, below and above onto、below、above のうち 1 つだけを指定してください
@msg column must be true or a number from 1 column には true または 1 以上の数値を指定してください
@msg unknown panel `{raw}` `{raw}` は不明なパネルです
@msg x and y must both be numbers x と y はどちらも数値で指定してください
@msg Downloaded {what} {what} をダウンロードしました
@@ -4088,3 +4094,6 @@ lighting Light ライト
Stroke Pressure ストロークの筆圧
Right Diagonal 右斜め
Left Diagonal 左斜め
Vertical Guide 垂直ガイド
Horizontal Guide 水平ガイド
Move Guide to Layer ガイドをレイヤーへ移動
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -1721,6 +1721,7 @@
Proof Colors Provar Cores
Proof Setup Configuração de Prova
Properties Propriedades
Proportional Metrics Métrica Proporcional
Pucker & Bloat… Encolher e Inchar…
Pucker Tool Ferramenta Encolher
Punctuation Space Espaço de Pontuação
@@ -2697,6 +2698,7 @@
% of all commits % de todos os commits
Lines +/− Linhas +/−
Drag to move Arraste para mover
Drag to move out of the set Arraste para tirar do conjunto
Drag to float as a toolbar Arraste para flutuar como barra de ferramentas
Put back in the toolbar Devolver à barra de ferramentas
Float Panel Flutuar Painel
@@ -2789,3 +2791,5 @@ lighting Light Luz
Spatter… Respingo…
Sprayed Strokes… Traços Borrifados…
Sumi-e… Sumi-e…
Vertical Guide Guia vertical
Horizontal Guide Guia horizontal
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10 -1
View File
@@ -2047,6 +2047,7 @@
Proof Colors Цветопроба
Proof Setup Настройка цветопробы
Properties Свойства
Proportional Metrics Пропорциональные метрики
Proportional Oldstyle Пропорциональные старого стиля
Pucker & Bloat… Сморщивание и раздувание…
Pucker Tool Инструмент «Сморщивание»
@@ -3313,6 +3314,7 @@ axis Horizontal Горизонтально
axis Vertical Вертикально
axis Both Оба
Drag to move Перетащите для перемещения
Drag to move out of the set Перетащите, чтобы вынести из набора
Drag to float as a toolbar Перетащите, чтобы сделать плавающей панелью инструментов
Put back in the toolbar Вернуть на панель инструментов
Float Panel Сделать панель плавающей
@@ -3755,6 +3757,7 @@ axis Both Оба
@msg the image's pixels don't match its size пиксели изображения не соответствуют его размеру
@msg the layer has no objects to clip by в слое нет объектов для обрезки
@msg the layer or group is locked слой или группа заблокированы
@msg the guide is on a hidden or locked layer направляющая находится на скрытом или заблокированном слое
@msg the target layer is locked or hidden целевой слой заблокирован или скрыт
@msg the layer to flatten into is hidden слой для объединения скрыт
@msg the object already has an opacity mask у объекта уже есть непрозрачная маска
@@ -3780,7 +3783,7 @@ axis Both Оба
@msg the {tool} tool has no options инструмент {tool} не имеет параметров
@msg there are no objects to release нет объектов для освобождения
@msg there are no printer settings to open here здесь нет настроек принтера для открытия
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it этот файл PostScript невозможно прочитать ({_1}): сохраните его как PDF или SVG в приложении, которое его создало
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it этот файл PostScript невозможно прочитать ({why}): сохраните его как PDF или SVG в приложении, которое его создало
@msg this file's PostScript couldn't be read ({why}): {what} PostScript этого файла невозможно прочитать ({why}): {what}
@msg this format holds one artboard: export several as PDF or with document.exportForScreens этот формат содержит одну монтажную область: экспортируйте несколько как PDF или с помощью 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 это чертёж DWG, который невозможно прочитать: сохраните его из приложения CAD как ASCII DXF и откройте его
@@ -3869,6 +3872,9 @@ axis Both Оба
@msg tool (a tool id) is required требуется tool (идентификатор инструмента)
@msg {tool} isn't in a toolbar group of several tools {tool} не входит в группу панели инструментов из нескольких инструментов
@msg onto must name a panel floating in another group onto должен называть панель, плавающую в другой группе
@msg below and above must name a panel floating in another group below и above должны называть панель, плавающую в другой группе
@msg give only one of onto, below and above укажите только один из onto, below и above
@msg column must be true or a number from 1 column должен быть true или числом от 1
@msg x and y must both be numbers x и y должны быть числами
@msg "{0}" is not a colour: write it as #rrggbb «{0}» не является цветом: запишите его как #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет»
@@ -4091,3 +4097,6 @@ lighting Light Свет
Stroke Pressure Нажим мазка
Right Diagonal Диагональ вправо
Left Diagonal Диагональ влево
Vertical Guide Вертикальная направляющая
Horizontal Guide Горизонтальная направляющая
Move Guide to Layer Переместить направляющую на слой
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10 -1
View File
@@ -2043,6 +2043,7 @@
Proof Colors Пробні кольори
Proof Setup Налаштування кольоропроби
Properties Властивості
Proportional Metrics Пропорційні метрики
Proportional Oldstyle Пропорційні цифри старого стилю
Pucker & Bloat… Зморщування та роздування …
Pucker Tool Інструмент «Зморщування»
@@ -3305,6 +3306,7 @@ axis Horizontal Горизонтальна
axis Vertical Вертикальна
axis Both Обидві
Drag to move Перетягніть для переміщення
Drag to move out of the set Перетягніть, щоб винести з набору
Drag to float as a toolbar Перетягніть, щоб від’єднати панель інструментів
Put back in the toolbar Повернути на панель інструментів
Float Panel Від’єднати панель
@@ -3742,6 +3744,7 @@ axis Both Обидві
@msg the image's pixels don't match its size пікселі зображення не відповідають його розміру
@msg the layer has no objects to clip by у шарі немає об’єктів для обрізки
@msg the layer or group is locked шар чи група заблоковані
@msg the guide is on a hidden or locked layer напрямна розташована на прихованому або заблокованому шарі
@msg the target layer is locked or hidden цільовий шар заблокований або прихований
@msg the layer to flatten into is hidden шар для об’єднання прихований
@msg the object already has an opacity mask об’єкт вже має маску непрозорості.
@@ -3767,7 +3770,7 @@ axis Both Обидві
@msg the {tool} tool has no options інструмент {tool} не має параметрів
@msg there are no objects to release немає об’єктів для звільнення
@msg there are no printer settings to open here тут немає налаштувань принтера для відкриття
@msg this PostScript file can't be read ({_1}): save it as PDF or SVG in the app that made it цей файл PostScript неможливо прочитати ({_1}): збережіть його як PDF або SVG у додатку, який його створив
@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it цей файл PostScript неможливо прочитати ({why}): збережіть його як PDF або SVG у додатку, який його створив
@msg this file's PostScript couldn't be read ({why}): {what} PostScript цього файлу неможливо прочитати ({why}): {what}
@msg this format holds one artboard: export several as PDF or with document.exportForScreens цей формат містить одну монтажну область: експортуйте кілька як PDF або за допомогою 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 це креслення DWG, яке неможливо прочитати: збережіть його з CAD як ASCII DXF і відкрийте його
@@ -3856,6 +3859,9 @@ axis Both Обидві
@msg tool (a tool id) is required потрібно tool (ідентифікатор інструменту)
@msg {tool} isn't in a toolbar group of several tools {tool} не входить до групи панелі інструментів з кількох інструментів
@msg onto must name a panel floating in another group onto має називати панель, що плаває в іншій групі
@msg below and above must name a panel floating in another group below і above мають називати панель, що плаває в іншій групі
@msg give only one of onto, below and above вкажіть лише один із onto, below і above
@msg column must be true or a number from 1 column має бути true або числом від 1
@msg x and y must both be numbers x і y мають бути числами
@msg "{0}" is not a colour: write it as #rrggbb "{0}" не є кольором: запишіть його як #rrggbb
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead це не логотип із плоских кольорів ({_1}% його суцільних областей збігається з кількома плоскими кольорами), тому режим «Плоский логотип» перетворив би градієнти на плями: використовуйте режим «Колір»
@@ -4074,3 +4080,6 @@ lighting Light Світло
Stroke Pressure Натиск мазка
Right Diagonal Діагональ праворуч
Left Diagonal Діагональ ліворуч
Vertical Guide Вертикальна напрямна
Horizontal Guide Горизонтальна напрямна
Move Guide to Layer Перемістити напрямну на шар
Can't render this file because it contains an unexpected character in line 659 and column 40.
+5
View File
@@ -1976,6 +1976,7 @@
Proof Colors 校样颜色
Proof Setup 校样设置
Properties 属性
Proportional Metrics 比例度量
Proportional Oldstyle 比例旧式数字
Pucker & Bloat… 缩拢与膨胀…
Pucker Tool 收缩工具
@@ -3517,6 +3518,7 @@ axis Horizontal 水平
axis Vertical 垂直
axis Both 两者
Drag to move 拖动以移动
Drag to move out of the set 拖动以移出该组合
Drag to float as a toolbar 拖动使其浮动为工具栏
Put back in the toolbar 放回工具栏
Float Panel 浮动面板
@@ -3723,3 +3725,6 @@ lighting Light 光源
Stroke Pressure 描边压力
Right Diagonal 右对角线
Left Diagonal 左对角线
Vertical Guide 垂直参考线
Horizontal Guide 水平参考线
Move Guide to Layer 将参考线移至图层
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -2029,6 +2029,7 @@
Proof Colors 校樣色彩
Proof Setup 校樣設定
Properties 內容
Proportional Metrics 比例度量
Proportional Oldstyle 比例舊式數字
Pucker & Bloat… 縮攏與膨脹…
Pucker Tool 縮攏工具
@@ -3295,6 +3296,7 @@ axis Horizontal 水平
axis Vertical 垂直
axis Both 兩者
Drag to move 拖曳以移動
Drag to move out of the set 拖曳以移出該組合
Drag to float as a toolbar 拖曳使其浮動為工具列
Put back in the toolbar 放回工具列
Float Panel 浮動面板
@@ -3504,3 +3506,6 @@ lighting Light 光源
Stroke Pressure 筆觸壓力
Right Diagonal 右對角線
Left Diagonal 左對角線
Vertical Guide 垂直參考線
Horizontal Guide 水平參考線
Move Guide to Layer 將參考線移至圖層
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12 -1
View File
@@ -788,6 +788,8 @@ impl VectorcraftApp {
return;
}
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY);
// The active document as the last look saw it: its uid and revision.
let (seen_key, doc) = (egui::Id::new("linkWatch.seen"), self.session.active().map(|d| (d.uid, d.revision)));
if now - last >= 2.0 || (focused && !was_focused) {
// Walking the document's links costs a pass over it: only when a look is due.
// `start_link_scan` starts none for a document without links, which then doesn't
@@ -795,12 +797,21 @@ impl VectorcraftApp {
ctx.data_mut(|d| d.insert_temp(last_key, now));
self.session.start_link_scan();
let links = self.session.link_scan.is_some();
ctx.data_mut(|d| d.insert_temp(egui::Id::new("linkWatch.links"), links));
ctx.data_mut(|d| {
d.insert_temp(egui::Id::new("linkWatch.links"), links);
d.insert_temp(seen_key, doc);
});
}
if self.session.link_scan.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
} else if ctx.data(|d| d.get_temp::<bool>(egui::Id::new("linkWatch.links"))).unwrap_or(false) {
ctx.request_repaint_after(std::time::Duration::from_millis(2000));
} else if ctx.data(|d| d.get_temp::<Option<(u64, u64)>>(seen_key)).is_some_and(|seen| seen != doc) {
// The document changed (a file placed into one without links) or another came to the
// front since that look: wake for the next one even if the app sits idle until then,
// or a file placed and changed meanwhile is first seen after the change, which is then
// never acted on.
ctx.request_repaint_after(std::time::Duration::from_secs_f64((last + 2.0 - now).clamp(0.0, 2.0)));
}
}
+3 -2
View File
@@ -260,13 +260,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"window.panel.float",
"Float Panel",
"",
"{panel: id or \"tools\", x?, y?, onto?: id, group?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it with the floating group holding that panel instead; `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved. \"tools\" floats the Tools panel. Returns {panel, floating, group, pos}",
"{panel: id or \"tools\", x?, y?, onto?: id, below?: id, above?: id, group?: bool, collapsed?: bool} float a panel out of the dock (or out of its floating group) as its own floating group with its top-left corner at x, y in window points (default: cascaded), as dragging its tab out of the dock does; `onto` stacks it as a tab with the floating group holding that panel instead; `below` / `above` puts its group in a set with that group, right below or above it, as dropping it on that group's lower or upper half does (the set's groups stack top to bottom and move together); `group` takes its whole group (its floating group, or the Properties | Layers | Libraries tabs left in the dock); a panel alone in its group is moved (out of its set, given x, y); `collapsed` shows its group's tabs only, as a double-click on a tab does; a docked group floats its dock column. \"tools\" floats the Tools panel. Returns {panel, floating, group, set, pos, collapsed, column}",
),
(
"window.panel.dock",
"Dock Panel",
"",
"{panel: id or \"tools\", group?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; \"tools\" docks the Tools panel at the window's left edge",
"{panel: id or \"tools\", group?: bool, column?: true or n} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}",
),
(
"window.collapseDock",
@@ -1126,6 +1126,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
&& let Some(g) = crate::floating::group_of(&app.ui, p).and_then(|i| app.ui.floating_panels.get_mut(i))
{
g.active = g.panels.iter().position(|q| q == p).unwrap_or(g.active);
g.collapsed = false;
let out = json!({ "floating": g.panels });
app.ui.dock = true;
return Some(Ok(out));
+140 -21
View File
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::{CharStyle, NodeId, NodeKind};
use vectorcraft_tools::{Mods, ToolKey};
use super::{first_selected, pstate, set_pstate};
use super::{pstate, set_pstate};
use crate::VectorcraftApp;
use crate::theme::Tokens;
use crate::widgets::{self, SpinPick, menu_item};
@@ -20,26 +20,62 @@ pub const SIZE_PRESETS: [f64; 15] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0,
pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0];
pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0];
/// The character style shown by the panel: the selected range's (or the caret's) while the Type
/// tool edits text, else the first selected text object's first run. With it, the paragraph
/// The stored character style: the selected range's (or the caret's) while the Type
/// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph
/// attributes of the paragraph the selection starts in (else of the first paragraph).
/// Font/paragraph controls and named-style capture use this; dimensional fields use `shared_effective`.
pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> {
if let Some((id, a, b)) = text_editing(app)
&& let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind)
{
return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para_at(t.paragraphs_in(a, a).start).clone()));
}
let n = first_selected(app)?;
match &n.kind {
NodeKind::Text(t) => Some((t.first_style(), t.para_at(0).clone())),
_ => None,
let t = first_selected_text(app)?;
Some((t.first_style(), t.para_at(0).clone()))
}
/// The first selected text descendant, shared by Character and Paragraph controls.
pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> {
let st = app.session.active()?;
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
let mut text = None;
node.walk(&mut |n| {
if text.is_none()
&& let NodeKind::Text(t) = &n.kind
{
text = Some(t.as_ref());
}
});
if text.is_some() {
return text;
}
}
None
}
/// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the
/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a
/// blank field) where they differ, or with no type.
pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option<T> {
shared_styles(app, |_, style| Some(f(style)))
}
/// Dimensional controls use document units and tolerate arithmetic roundoff when comparing
/// compensated runs. Keep raw styles available to font/paragraph controls and style definitions,
/// even if a collapsed affine prevents editing dimensions.
fn shared_effective(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> f64) -> Option<f64> {
shared_styles(app, |t, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0)
}
struct Metric(f64);
impl PartialEq for Metric {
fn eq(&self, other: &Self) -> bool {
(self.0 - other.0).abs() <= 1e-9 * self.0.abs().max(other.0.abs()).max(1.0)
}
}
fn shared_styles<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc::TextObject, &CharStyle) -> Option<T>) -> Option<T> {
let st = app.session.active()?;
let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t),
@@ -49,26 +85,26 @@ pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle)
if let Some((id, a, b)) = text_editing(app) {
let t = text(id)?;
if b <= a {
return Some(f(&vectorcraft_text::edit::insertion_style(&t.runs, a, b)));
return f(t, &vectorcraft_text::edit::insertion_style(&t.runs, a, b));
}
let mut end = 0;
for r in &t.runs {
let start = end;
end += r.text.len();
if start < b && end > a {
acc.add(f(&r.style));
acc.add(f(t, &r.style));
}
}
} else {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
t.runs.iter().for_each(|r| acc.add(f(&r.style)));
t.runs.iter().for_each(|r| acc.add(f(t, &r.style)));
}
});
}
}
acc.value()
acc.value().flatten()
}
/// The value a run of styles share so far ([`shared`]).
@@ -402,7 +438,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
egui::Grid::new(id).num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
cell(ui, "T", tl!("Font Size"), |ui| size_field(app, ui, "ch-size", fw));
cell(ui, "A↕", tl!("Leading"), |ui| {
let leading = shared(app, CharStyle::effective_leading);
let leading = shared_effective(app, CharStyle::effective_leading);
let auto = shared(app, |s| s.leading.is_none()) == Some(true);
match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) {
Some(SpinPick::Value(v)) => style(app, json!({"leading": v})),
@@ -427,18 +463,18 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
return;
}
cell(ui, "IT", tl!("Vertical Scale %"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-vs", s.v_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
if let Some(v) = widgets::spin_plain(ui, "ch-vs", shared(app, |s| s.v_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
format(app, json!({"vScale": v}));
}
});
cell(ui, "T↔", tl!("Horizontal Scale %"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-hs", s.h_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
if let Some(v) = widgets::spin_plain(ui, "ch-hs", shared_effective(app, |s| s.h_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
format(app, json!({"hScale": v}));
}
});
ui.end_row();
cell(ui, "Aª", tl!("Baseline Shift"), |ui| {
if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) {
if let Some(v) = widgets::spin_field(ui, "ch-bs", shared_effective(app, |s| s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) {
format(app, json!({"baselineShift": v}));
}
});
@@ -454,7 +490,7 @@ pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &
/// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or
/// picked from the presets, in the type unit, blank where the selected text's sizes differ.
pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) {
let size = shared(app, |s| s.size);
let size = shared_effective(app, |s| s.size);
if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) {
style(app, json!({"size": v}));
}
@@ -619,6 +655,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
}
});
});
// Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`).
let proportional = s.as_ref().is_some_and(|s| s.proportional_metrics);
if menu_item(ui, tl!("Proportional Metrics"), has, proportional) {
format(app, json!({"proportionalMetrics": !proportional}));
}
ui.separator();
}
for l in ["Standard Vertical Roman Alignment", "Tate-chu-yoko", "Fractional Widths", "System Layout", "No Break"] {
@@ -646,6 +687,81 @@ mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn scaled_dimensions_do_not_show_false_mixed_values_from_roundoff() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone();
app.run("object.scale", json!({"sx": 120, "sy": 110})).unwrap();
let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("text.setStyle", json!({"size": 7, "leading": 9})).unwrap();
app.run("text.setFormat", json!({"hScale": 130, "baselineShift": 3})).unwrap();
for (actual, expected) in [
(shared_effective(&app, |s| s.size), 7.0),
(shared_effective(&app, CharStyle::effective_leading), 9.0),
(shared_effective(&app, |s| s.h_scale), 130.0),
(shared_effective(&app, |s| s.baseline_shift), 3.0),
] {
assert!((actual.expect("equal dimensions must not be mixed") - expected).abs() < 1e-8);
}
}
#[test]
fn scaled_collapsed_text_keeps_nondimensional_controls_available() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap();
app.run("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})).unwrap();
assert!(text_style(&app).is_some());
assert_eq!(shared(&app, |s| s.tracking), Some(0.0));
assert_eq!(shared_effective(&app, |s| s.size), None);
app.run("text.setStyle", json!({"tracking": 40})).unwrap();
app.run("text.setFormat", json!({"leftIndent": 4})).unwrap();
assert_eq!(shared(&app, |s| s.tracking), Some(40.0));
assert_eq!(text_style(&app).unwrap().1.left_indent, 4.0);
let ctx = egui::Context::default();
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show);
assert!(!texts.iter().any(|(s, _)| s == "No text selected"));
}
#[test]
fn scaled_character_fields_show_document_sizes_and_mixed_group_values() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let a = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis", "size": 12})).unwrap()["id"].clone();
app.run("object.scale", json!({"sx": 200})).unwrap();
assert_eq!(shared_effective(&app, |s| s.size), Some(24.0));
assert_eq!(text_style(&app).unwrap().0.size, 12.0, "style definitions keep stored attributes");
let b = app.run("text.create", json!({"x": 20, "y": 90, "text": "Label", "size": 24})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("object.group", json!({})).unwrap();
assert_eq!(shared_effective(&app, |s| s.size), Some(24.0), "different local sizes, same displayed size");
app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap();
assert_eq!(shared_effective(&app, |s| s.h_scale), Some(200.0));
app.run("text.setStyle", json!({"size": 10})).unwrap();
assert_eq!(shared_effective(&app, |s| s.size), Some(10.0));
app.run("text.setFormat", json!({"hScale": 100})).unwrap();
assert_eq!(shared_effective(&app, |s| s.h_scale), Some(100.0));
}
#[test]
fn scaled_range_fields_follow_the_type_tool_selection() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app.session.select_tool("type", Default::default()).unwrap();
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Axis label", "size": 12})).unwrap()["id"].clone();
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
assert!(text_editing(&app).is_some());
app.run("object.scale", json!({"sx": 200})).unwrap();
app.run("text.setRangeStyle", json!({"id": id, "start": 0, "end": 4, "size": 10})).unwrap();
app.session.set_tool_option("selectAll", &json!(true));
assert_eq!(shared_effective(&app, |s| s.size), None);
app.session.set_tool_option("select", &json!({"start": 0, "end": 4}));
assert_eq!(shared_effective(&app, |s| s.size), Some(10.0));
assert_eq!(text_style(&app).unwrap().0.size, 5.0);
}
/// Bold and Italic pick the family's own faces by name, and toggle back (#724).
#[test]
fn bold_and_italic_pick_the_familys_own_faces() {
@@ -673,17 +789,20 @@ mod tests {
assert!(!crate::menus::enabled(&app, "type.bold"));
}
/// Character Alignment is in the panel menu with the East Asian options only (as Mojikumi Set
/// and Top-to-Top Leading are in the Paragraph panel).
/// Character Alignment and Proportional Metrics are in the panel menu with the East Asian
/// options only (as Mojikumi Set and Top-to-Top Leading are in the Paragraph panel).
#[test]
fn character_alignment_shows_with_the_east_asian_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "雅楽"})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
let shown = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, menu).contains("Character Alignment");
assert!(!shown(&mut app));
let shown = |app: &mut VectorcraftApp| {
let text = crate::tests_labels::painted_text(app, menu);
(text.contains("Character Alignment"), text.contains("Proportional Metrics"))
};
assert_eq!(shown(&mut app), (false, false));
app.session.prefs.show_east_asian_options = true;
assert!(shown(&mut app));
assert_eq!(shown(&mut app), (true, true));
}
}
+129 -6
View File
@@ -98,6 +98,9 @@ enum LayersDrag {
Rows(Vec<NodeId>),
/// The selected-art square: the selected objects go to the row it is dropped on.
Art,
/// Ruler guide rows (indexes into the document's guides): they go to the layer of the row
/// they are dropped on.
Guides(Vec<usize>),
}
/// A drag down the eye or lock column: every row it passes takes `value`.
@@ -220,6 +223,8 @@ struct Row {
colour: Color32,
/// Drawn dimmed: hidden itself or in a hidden layer or group, or a template layer.
dim: bool,
/// A ruler guide on the layer `node` (its index in the document's guides): the guide's row.
guide: Option<usize>,
}
/// What the rows were listed from: listed again when any of it changes.
@@ -239,7 +244,7 @@ fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str)
let mask_layer = doc.mask_edit.map(|m| m.layer);
let mut out = vec![];
for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) {
push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query), &mut out);
push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, doc), &mut out);
}
out
}
@@ -251,10 +256,10 @@ fn push_rows(
depth: usize,
clip_path: bool,
(colour, shown): (Color32, bool),
cx: (&OpenRows, &PanelOptions, &str),
cx: (&OpenRows, &PanelOptions, &str, &Document),
out: &mut Vec<Row>,
) {
let (open_rows, opts, query) = cx;
let (open_rows, opts, query, doc) = cx;
if opts.layers_only && !n.is_layer() {
return;
}
@@ -266,11 +271,18 @@ fn push_rows(
_ => colour,
};
let shown = shown && n.visible;
let opens = opens(n, opts);
let searching = !query.is_empty();
// A layer's ruler guides are rows in it, above its art (not while searching).
let guides: Vec<usize> = if n.is_layer() && !opts.layers_only && !searching { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] };
let opens = opens(n, opts) || !guides.is_empty();
let open = opens && (searching || open_rows.contains(n.id));
let at = out.len();
out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template() });
out.push(Row { node: n.clone(), depth, clip_path, opens, open, colour, dim: !shown || n.is_template(), guide: None });
if open {
for i in guides.into_iter().rev() {
out.push(Row { node: n.clone(), depth: depth + 1, clip_path: false, opens: false, open: false, colour, dim: !shown, guide: Some(i) });
}
}
if open && let Some(children) = n.children() {
for (i, c) in children.iter().enumerate().rev() {
push_rows(c, depth + 1, i == 0 && n.clips(), (colour, shown), cx, out);
@@ -302,6 +314,8 @@ fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions,
struct View<'a> {
doc: &'a Document,
sel: HashSet<NodeId>,
/// The selected ruler guides.
guides: Vec<usize>,
target: Option<NodeId>,
current: Option<NodeId>,
rows: Vec<NodeId>,
@@ -362,6 +376,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let view = View {
doc: &doc,
sel: st.selection.objects.iter().copied().collect(),
guides: st.selection.guides.clone(),
target: st.selection.target,
current: st.current_layer(),
rows,
@@ -537,13 +552,77 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool {
NodeKind::Group { .. } => match drag {
LayersDrag::Rows(ids) => !ids.iter().any(|i| doc.node(*i).is_some_and(Node::is_layer)),
LayersDrag::Art => true,
LayersDrag::Guides(_) => false,
},
_ => false,
}
}
/// The row of ruler guide `i` on the layer of `item`: a guide mark and its name, the selection
/// square when it is selected. A click selects the guide; Shift or Cmd adds it to (or takes it
/// from) the selected guides. A guide on a hidden or locked layer can't be selected or dragged.
fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) {
let (t, h) = (&view.t, view.h);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag());
let Some(g) = view.doc.guides.get(i) else { return };
let selected = view.guides.contains(&i);
let editable = view.doc.guide_editable(g);
if editable && resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6));
}
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border));
let mut x = r.left() + 2.0 * COLUMN + 2.0;
colour_bar(ui, &item.node, r, x, item.colour);
x += 6.0 + item.depth as f32 * INDENT + 16.0;
// The guide mark: a short line across or down, in the guides' colour.
let c = egui::pos2(x + 8.0, r.center().y);
let half = if g.vertical { vec2(0.0, 6.0) } else { vec2(6.0, 0.0) };
ui.painter().line_segment([c - half, c + half], Stroke::new(1.5, t.accent));
x += 22.0;
let name = format!("<{}>", if g.vertical { tl!("Vertical Guide") } else { tl!("Horizontal Guide") });
let color = if item.dim { t.text_dim } else { t.text };
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), color);
let col_x = r.right() - 43.5;
ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border));
if selected {
let q = egui::Rect::from_center_size(egui::pos2(r.right() - SQUARE_X, r.center().y), vec2(7.0, 7.0));
ui.painter().rect_filled(q, 0.0, item.colour);
ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, t.text), StrokeKind::Inside);
}
if editable && resp.clicked() {
let m = ui.input(|i| i.modifiers);
out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command})));
}
// Dragged: the selected guides when this is one of them, else this one, to another layer's row.
if editable && resp.drag_started() {
let guides = if selected { view.guides.clone() } else { vec![i] };
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides));
}
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
// Guides dropped here go to this guide's layer.
if let Some(drag) = resp.dnd_hover_payload::<LayersDrag>()
&& let LayersDrag::Guides(guides) = &*drag
{
guide_drop(ui, view, guides, item.node.id, r, &resp, out);
}
}
/// Guide rows `guides` dragged over row `r` (`resp`) of layer `layer`: the row lights up, and the
/// release puts them on that layer (`guide.setLayer`, one undo step).
fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Rect, resp: &egui::Response, out: &mut Out) {
ui.painter().rect_stroke(r.shrink(1.0), 0.0, Stroke::new(2.0, view.t.accent), StrokeKind::Inside);
if resp.dnd_release_payload::<LayersDrag>().is_some() {
out.actions.push(("guide.setLayer".into(), json!({"indexes": guides, "layer": layer.0})));
}
}
/// The widgets of row `item`.
fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
if let Some(i) = item.guide {
return guide_row(ui, view, item, i, out);
}
let n = &*item.node;
let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour);
let doc = view.doc;
@@ -777,6 +856,13 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open:
let moving: Vec<NodeId> = match &*drag {
LayersDrag::Rows(ids) => ids.clone(),
LayersDrag::Art => view.sel.iter().copied().collect(),
LayersDrag::Guides(guides) => {
// Guides go to the layer of the row (the row's own, for a layer).
if let Some(layer) = if n.is_layer() { Some(n.id) } else { doc.layer_containing(n.id) } {
guide_drop(ui, view, guides, layer, r, resp, out);
}
return;
}
};
// Never into itself or a row inside it.
let around = doc.ancestry(n.id).unwrap_or_default();
@@ -791,7 +877,7 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open:
LayersDrag::Art => Place::Above,
LayersDrag::Rows(_) if inside && rel >= 0.25 && (rel <= 0.75 || open) => Place::Inside,
LayersDrag::Rows(_) if rel < 0.5 => Place::Above,
LayersDrag::Rows(_) => Place::Below,
LayersDrag::Rows(_) | LayersDrag::Guides(_) => Place::Below,
};
let stroke = Stroke::new(2.0, view.t.accent);
match place {
@@ -811,6 +897,7 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open:
// The selection's top objects, as Arrange and Group take them.
LayersDrag::Art => doc.paint_order(moving.iter().copied()).iter().map(|i| i.0).collect(),
LayersDrag::Rows(ids) => ids.iter().map(|i| i.0).collect(),
LayersDrag::Guides(_) => return,
};
out.actions.push(("layer.move".into(), json!({"ids": ids, "target": n.id.0, "place": place.id(), "copy": copy})));
}
@@ -1426,6 +1513,42 @@ mod tests {
assert_eq!(d.layers.len(), 1);
}
/// A layer's ruler guides are rows in it, above its art: a click selects the guide, a drag onto
/// another layer's row moves it there, and a guide on a locked layer is left alone.
#[test]
fn guide_rows_select_and_move_their_guides() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}));
run(&mut app, "guide.add", json!({"vertical": true, "pos": 50}));
run(&mut app, "select.none", json!({}));
let second = NodeId(run(&mut app, "layer.new", json!({}))["id"].as_u64().unwrap());
expand(&mut app, &json!({})).unwrap();
let ctx = egui::Context::default();
// Rows top down: Layer 2, Layer 1, its guide (no target circle), the rectangle.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 3);
let guide_row = egui::pos2(150.0, (circles[1].y + circles[2].y) / 2.0);
let guides = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone();
click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE);
assert_eq!(guides(&app), [0], "its row selects it");
// Dragged onto Layer 2's row, it goes on Layer 2, in one undo step.
let undo = app.session.active().unwrap().history.undo.len();
drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[0].y), false);
let st = app.session.active().unwrap();
assert_eq!((st.doc.guides[0].layer, st.history.undo.len()), (Some(second), undo + 1));
// Now under Layer 2: locked, its row neither selects nor drags it.
run(&mut app, "layer.setProps", json!({"ids": [second.0], "locked": true}));
assert!(guides(&app).is_empty(), "deselected with its layer locked");
let (circles, _) = frame(&mut app, &ctx, vec![], false);
let guide_row = egui::pos2(150.0, (circles[0].y + circles[1].y) / 2.0);
click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE);
assert!(guides(&app).is_empty());
drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[2].y), false);
assert_eq!(app.session.active().unwrap().doc.guides[0].layer, Some(second));
}
/// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows
/// holding selected art).
fn squares(app: &mut VectorcraftApp, ctx: &egui::Context) -> (usize, usize) {
+7 -3
View File
@@ -42,9 +42,11 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
/// right to left: its Paragraph Direction, else from its first strong character?
fn resolved_rtl(app: &VectorcraftApp, para: &ParaStyle) -> bool {
let editing = super::character::text_editing(app);
let selected = super::first_selected(app);
let node = editing.and_then(|(id, _, _)| app.session.active().and_then(|d| d.doc.node(id))).or(selected.as_ref());
let Some(NodeKind::Text(t)) = node.map(|n| &n.kind) else { return para.direction == Some(ParaDirection::RightToLeft) };
let text = editing.and_then(|(id, _, _)| match app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.as_ref()),
_ => None,
});
let Some(t) = text.or_else(|| super::character::first_selected_text(app)) else { return para.direction == Some(ParaDirection::RightToLeft) };
let plain = t.plain_text();
let paragraph = vectorcraft_text::edit::paragraph_at(&plain, editing.map_or(0, |(_, a, _)| a));
vectorcraft_text::paragraph_is_rtl(plain.get(paragraph).unwrap_or_default(), para.direction)
@@ -246,6 +248,8 @@ mod tests {
shown_alignment(para.justify, resolved_rtl(app, &para))
};
assert_eq!(shown(&app), Justify::Right);
app.session.execute("object.group", &json!({})).unwrap();
assert_eq!(shown(&app), Justify::Right, "group controls resolve the same text descendant's direction");
app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
assert_eq!(shown(&app), Justify::Left);
app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap();
+54 -2
View File
@@ -442,12 +442,26 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("object.rotate", json!({"angle": a, "absolute": true})).ok();
}
ui.add_space(10.0);
if widgets::icon_button(ui, "flip-horizontal-2", tl!("Flip Along Horizontal Axis"), false, 24.0).clicked() {
// Lucide's flip icons are named for their mirror line: flip-vertical-2 draws a vertical
// line (the left-right flip), flip-horizontal-2 a horizontal one (top-bottom).
if widgets::icon_button(ui, "flip-vertical-2", tl!("Flip Along Horizontal Axis"), false, 24.0).clicked() {
app.run("object.reflect", json!({"axis": "vertical"})).ok();
}
if widgets::icon_button(ui, "flip-vertical-2", tl!("Flip Along Vertical Axis"), false, 24.0).clicked() {
if widgets::icon_button(ui, "flip-horizontal-2", tl!("Flip Along Vertical Axis"), false, 24.0).clicked() {
app.run("object.reflect", json!({"axis": "horizontal"})).ok();
}
// More Options: the Transform panel's Scale Corners and Scale Strokes & Effects, open
// while they are toggled.
let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, 24.0);
egui::Popup::menu(&more).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes);
if widgets::check(ui, tl!("Scale Corners"), sc, true) {
super::transform::set_pref(app, "scaleCorners", !sc);
}
if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) {
super::transform::set_pref(app, "scaleStrokes", !ss);
}
});
});
// Live shape properties.
if let Some(n) = first_selected(app)
@@ -623,6 +637,44 @@ mod tests {
assert!(widths.iter().all(|w| (w - widths[0]).abs() < 1.0), "X, rotation, corner radius, opacity: {widths:?}");
}
/// The Transform section's More Options holds Scale Corners and Scale Strokes & Effects, and
/// stays open while they are toggled.
#[test]
fn transform_more_options_toggles_scale_corners_and_strokes() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
let ctx = egui::Context::default();
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
assert!(!has(&texts, "Scale Corners"), "closed at first");
// The button is an icon at the end of the angle row, under the H: field: sweep that row.
let h = texts.iter().find(|(t, _)| t == "H:").unwrap().1;
let mut open = None;
'sweep: for dy in (12..60).step_by(6) {
for x in (120..300).step_by(6) {
let at = egui::pos2(x as f32, h.bottom() + dy as f32);
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(at), b(true)]);
frame(&mut app, &ctx, vec![b(false)]);
let texts = frame(&mut app, &ctx, vec![]);
if has(&texts, "Scale Corners") {
open = Some(texts);
break 'sweep;
}
}
}
let texts = open.expect("More Options opens the popup");
let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes);
click(&mut app, &ctx, &texts, "Scale Corners");
assert_eq!(app.session.prefs.scale_corners, !sc);
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Scale Strokes & Effects"), "still open after a toggle");
click(&mut app, &ctx, &texts, "Scale Strokes & Effects");
assert_eq!(app.session.prefs.scale_strokes, !ss);
}
/// Both panels forward the Move Artwork toggle when their actual X field is edited.
#[test]
fn artboard_coordinate_fields_move_art_in_properties_and_transform() {
@@ -61,6 +61,38 @@ fn a_picked_font_size_preset_applies_in_one_undo_step() {
assert_eq!(undo_len(&app), undo + 1, "one undo step");
}
#[test]
fn scaled_font_size_fields_and_preset_use_document_points() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let (mut app, id) = app_with_text();
run(&mut app, "object.scale", json!({"sx": 200}));
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt"));
assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt"));
let at = Cell::new(egui::Pos2::ZERO);
frame_events(&ctx, &mut app, vec![], size_combo(&at));
click(&ctx, &mut app, at.get() + egui::vec2(110.0, 13.0), size_combo(&at));
let t = frame_events(&ctx, &mut app, vec![], size_combo(&at));
let undo = undo_len(&app);
click(&ctx, &mut app, text_rect(&t, "10 pt").center(), size_combo(&at));
assert_eq!(style(&app, id).size * 2.0, 10.0);
assert_eq!(undo_len(&app), undo + 1);
assert!(shows(&frame_events(&ctx, &mut app, vec![], size_combo(&at)), "10 pt"));
}
#[test]
fn scaled_group_shows_character_and_control_bar_size_fields() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let (mut app, _) = app_with_text();
run(&mut app, "object.group", json!({}));
run(&mut app, "object.scale", json!({"sx": 200}));
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt"));
assert!(shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "24 pt"));
run(&mut app, "text.setStyle", json!({"size": 10}));
assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "10 pt"));
}
#[test]
fn font_size_presets_show_in_the_type_unit() {
let ctx = egui::Context::default();
+4 -2
View File
@@ -167,10 +167,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
});
ui.horizontal(|ui| {
if widgets::icon_button_enabled(ui, "flip-horizontal-2", tl!("Flip Horizontal"), false, has, 24.0).clicked() {
// Lucide's flip icons are named for their mirror line: flip-vertical-2 draws a vertical
// line (the left-right flip), flip-horizontal-2 a horizontal one (top-bottom).
if widgets::icon_button_enabled(ui, "flip-vertical-2", tl!("Flip Horizontal"), false, has, 24.0).clicked() {
app.run("object.reflect", json!({"axis": "vertical", "origin": origin})).ok();
}
if widgets::icon_button_enabled(ui, "flip-vertical-2", tl!("Flip Vertical"), false, has, 24.0).clicked() {
if widgets::icon_button_enabled(ui, "flip-horizontal-2", tl!("Flip Vertical"), false, has, 24.0).clicked() {
app.run("object.reflect", json!({"axis": "horizontal", "origin": origin})).ok();
}
});
+6 -3
View File
@@ -624,7 +624,8 @@ mod tests {
out.textures_delta.clear();
};
let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone());
let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
// The undo steps and the journal entries (the dialog's OK runs its command, which the journal records).
let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len());
app.select_tool("selection");
frame(&mut app, vec![enter()]);
assert_eq!(kind(&app), None, "nothing is selected");
@@ -645,9 +646,11 @@ mod tests {
frame(&mut app, vec![]);
assert_eq!(kind(&app).as_deref(), Some(dialog), "{tool}: and it stays open");
assert_eq!(steps(&app), before, "{tool}: the Enter that opened it didn't confirm it");
// The next Enter is its OK.
// The next Enter is its OK. With its default values only Reflect changes the art, so only
// Reflect records an undo step.
frame(&mut app, vec![enter()]);
assert_eq!((kind(&app), steps(&app)), (None, before + 1), "{tool}: Enter confirms");
let after = (before.0 + usize::from(dialog == "reflect"), before.1 + 1);
assert_eq!((kind(&app), steps(&app)), (None, after), "{tool}: Enter confirms");
}
app.select_tool("rectangle");
frame(&mut app, vec![enter()]);
+52 -1
View File
@@ -434,7 +434,12 @@ pub struct TaskBarPlace {
}
/// A group of panels dragged out of the dock: it floats as a stack of tabs, one panel shown.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
///
/// Groups sharing a `column` float as one set, stacked top to bottom in list order, like the panel
/// groups of a dock in Illustrator's workspace
/// (<https://helpx.adobe.com/illustrator/using/workspace-basics.html>): the set moves by the top
/// group's title bar, and a group dragged by its tab strip leaves it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct FloatingPanels {
/// Panel ids (`window.panel`), in tab order.
pub panels: Vec<String>,
@@ -446,6 +451,17 @@ pub struct FloatingPanels {
/// A width the group was given (the Tabs panel sized over its text), else its panels' own.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<f32>,
/// The set this group is stacked in with others (the same number), top to bottom in list
/// order; none when it floats on its own.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<u32>,
/// Collapsed to its tabs (a double-click on a tab), its panel hidden.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub collapsed: bool,
/// Docked as a column of the dock (with the rest of its set) instead of floating: the columns
/// stand left of the icon column, 1 next to it, counting left.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docked: Option<u32>,
}
impl FloatingPanels {
@@ -458,10 +474,45 @@ impl FloatingPanels {
g.active = g.active.min(g.panels.len().saturating_sub(1));
!g.panels.is_empty()
});
Self::tidy(groups);
if toolbar_pos.is_some_and(|p| !p.iter().all(|v| v.is_finite())) {
*toolbar_pos = None;
}
}
/// Keep each set's groups together in the list, after its first one and in the order they
/// come, at its first one's position and in its dock column; a set left with one group floats
/// (or stays docked) on its own; the dock columns are numbered 1, 2… without gaps.
pub fn tidy(groups: &mut Vec<FloatingPanels>) {
let mut out: Vec<FloatingPanels> = Vec::with_capacity(groups.len());
for mut g in groups.drain(..) {
match g.column.and_then(|c| out.iter().rposition(|o| o.column == Some(c))) {
Some(i) => {
if let Some(first) = out.iter().find(|o| o.column == g.column) {
g.pos = first.pos;
g.docked = first.docked;
}
out.insert(i + 1, g);
}
None => out.push(g),
}
}
let alone: Vec<u32> = out.iter().filter_map(|g| g.column).filter(|c| out.iter().filter(|g| g.column == Some(*c)).count() < 2).collect();
for g in &mut out {
if g.column.is_some_and(|c| alone.contains(&c)) {
g.column = None;
}
}
let mut numbers: Vec<u32> = out.iter().filter_map(|g| g.docked).collect();
numbers.sort_unstable();
numbers.dedup();
for g in &mut out {
if let Some(d) = g.docked {
g.docked = numbers.iter().position(|n| *n == d).and_then(|k| u32::try_from(k + 1).ok());
}
}
*groups = out;
}
}
impl UiState {
+65
View File
@@ -546,3 +546,68 @@ fn crop_image_shows_a_box_that_apply_crops_to() {
app.run("select.none", json!({})).unwrap();
assert!(!crate::menus::enabled(&app, "ui.cropImage") && app.run("ui.cropImage", json!({})).is_err());
}
/// One headless frame of the whole window at `time` → how long the app asked to wait before the
/// next one.
fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> std::time::Duration {
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(time), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out.viewport_output.get(&egui::ViewportId::ROOT).map_or(std::time::Duration::MAX, |v| v.repaint_delay)
}
/// The window sits idle for `secs` from `*time` (a frame now, then only the frames the app asks
/// for) or until `done` → whether it was.
fn idle(app: &mut VectorcraftApp, ctx: &egui::Context, time: &mut f64, secs: f64, done: &dyn Fn(&VectorcraftApp) -> bool) -> bool {
let end = *time + secs;
loop {
let wait = frame_at(app, ctx, *time);
if done(app) {
return true;
}
let next = *time + wait.as_secs_f64().max(1.0 / 60.0);
if next > end {
*time = end;
return false;
}
if app.session.link_scan.is_some() {
// The look runs on a worker thread, in real time.
std::thread::sleep(std::time::Duration::from_millis(5));
}
*time = next;
}
}
/// A file placed into a document whose last look found no links, then changed by another app
/// while the window sits idle in the background, follows Update Links: Automatically: the app
/// wakes itself to see the new link before the change, so the change isn't taken for the file as
/// first seen.
#[test]
fn a_file_placed_while_idle_follows_its_changes() {
let mut app = app();
app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap();
let ctx = egui::Context::default();
let mut time = 0.0;
idle(&mut app, &ctx, &mut time, 3.0, &|_| false);
// A look finds the new document without links: no wakes of its own for it.
assert_eq!(frame_at(&mut app, &ctx, time), std::time::Duration::MAX, "{:?}", ctx.repaint_causes());
// Half a second later, before the next look is due, a file is placed. The place itself is a
// frame (as any command); then nothing happens for 3 s.
time += 0.5;
let path = temp_file("idle-watch.png", &png(40, 30, 72.0));
app.run("file.place", json!({"path": path})).unwrap();
let id = app.session.active().unwrap().selection.objects[0];
idle(&mut app, &ctx, &mut time, 3.0, &|_| false);
std::thread::sleep(std::time::Duration::from_millis(30));
let changed = png(60, 30, 72.0);
std::fs::write(&path, &changed).unwrap();
let hash = vectorcraft_doc::links::hash_bytes(&changed);
let updated = |app: &VectorcraftApp| {
let st = app.session.active().unwrap();
matches!(&st.doc.node(id).unwrap().kind, NodeKind::Image(im) if im.link.as_ref().and_then(|l| l.hash.as_deref()) == Some(hash.as_str()))
};
assert!(idle(&mut app, &ctx, &mut time, 10.0, &updated), "the changed file was never read again");
}
+27 -6
View File
@@ -256,17 +256,38 @@ Constrain acts as Shift held (proportional scaling, moves and rotations by 45°)
where it is greyed. The modifier keys still work while dragging (Cmd on a corner distorts it freely, Cmd+Alt+Shift in
perspective, Cmd on a side shears).
Ruler guides are layer objects: `guide.add` puts a guide on the current layer (or `layer`), where it shows, hides,
locks and is deleted with its layer and is listed in the Layers panel (a click on its row selects it, a drag onto
another layer's rows moves it there); `guide.setLayer {index?, indexes?, layer}` moves guides to another layer, and
`guide.list` gives each guide's `layer`, `shown` and `editable`. Guides on a hidden or locked layer leave the selection
and can't be picked, moved or deleted, and no guide goes onto a locked layer; Duplicate copies a layer's guides, Merge
Selected and Flatten Artwork keep them on the merged layer (Flatten discards a hidden layer's). Guides in files saved
before guides had layers go on the top visible, unlocked layer when the document opens.
Floating panels: dragging a dock tab, a panel icon or a popped-out panel's title out of the dock floats that panel
inside the window, and the strip right of the dock's tabs floats the whole Properties | Layers | Libraries group. A
floating group moves by its title bar (or the strip right of its tabs); a tab dragged out of it floats on its own;
dropped on another group's title bar or tabs it stacks with that group, and dropped on the dock (lit up while the
pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column.
`window.panel.float {panel, x?, y?, onto?, group?}` floats a panel (with `group`, its whole group) with its top-left
corner at `x`, `y` (window points; default cascaded), or stacks it with the floating group holding `onto`;
`window.panel.dock {panel, group?}` puts it back. `panel` takes the `window.panel` ids and labels, and `"tools"` for
pointer is over it) or closed with its × its panels go back to the tabbed group or the icon column. Dropped on the upper or
lower half of another group, floating or docked (a line shows where), a group joins it in a set right above or below
it: the groups stack top to bottom, each
showing one of its panels, and move together by the top group's title bar, whose × docks them all; a group dragged by
its tab strip (a lone panel's tab or the strip right of the tabs) leaves the set. A double-click on a tab collapses a
group to its tabs, and another expands it. Dropped on the left edge of the icon column or of a dock column, a set
docks as a new dock column there: its groups stack in it as in a floating set, take groups dropped on their edges and
tabs, and leave it dragged by their tab strip; the strip along the column's top floats the whole column again, and
its × puts its panels back where they live.
`window.panel.float {panel, x?, y?, onto?, below?, above?, group?, collapsed?}` floats a panel (with `group`, its whole
group) with its top-left corner at `x`, `y` (window points; default cascaded), or stacks it as a tab with the floating
group holding `onto`, or puts its group in a set right `below` or `above` the group holding that panel; `collapsed`
collapses or expands its group;
`window.panel.dock {panel, group?, column?}` puts it back, or with `column` (`true`, or the place: 1 next to the icon
column, counting left) docks its group and set as a dock column; `window.panel.float` on a docked panel floats its
column. `panel` takes the `window.panel` ids and labels, and `"tools"` for
the Tools panel, which floats by its title bar and docks on the window's left edge. `window.panel` on a floating
panel shows its tab. `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the index of the tab shown,
the top-left corner) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the
panel shows its tab (expanding its group). `ui.floating_panels` in `ui.inspect` lists the groups (panel ids, the
index of the tab shown, the top-left corner, `column`: the set it is stacked in, top to bottom in list order,
`collapsed`, and `docked`: the dock column it stands in) and `ui.toolbar_pos` the Tools panel's corner (null: docked); both are saved with the
preferences and in user workspaces (the built-in workspaces dock everything), and a corner saved on a bigger window
is clamped into this one when drawn. Floating panels stay inside the app window (no separate OS windows).
+10 -5
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, from the `FORMATS` table and Illustrator 2026's documented formats) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Lab spot colours in `.ai` and PDF, #1032) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public
@@ -29,20 +29,22 @@ private editing data. VectorCraft:
- **Reads** the PDF page (vectors, text, images, colour, transparency, layers via optional content) and the
*structure* of the editing data (below). Opens Illustrator EPS the same way through its own PostScript
Level 3 interpreter.
Level 3 interpreter. A `.ai` in PostScript form (versions 3 to 8, and what Rhino and other CAD apps
write) is its own editing data: it opens through the layers its program has, compared with its page
when the program runs, from the layers alone when it names its prolog without including it (#1027).
- **Writes** a PDF-compatible `.ai` (Save As › `ai`): Illustrator opens it as plain art. It never writes
Illustrator's private editing data, which is undocumented.
- **Known failures in users' files:** layer structure flattened or multiplied (#951: 109 layers instead of
14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction
lines lost (#779), some files 4× slower to open since the editing data is read whole (#758), live effects,
brushes, symbols and pattern fills come in as their drawn look (#637), legacy `.ai` without the prolog not
read.
brushes and symbols come in as their drawn look (#637; pattern fills are read since #1025); legacy `.ai`
without the prolog opens since #1027, its legacy type (`To` … `TO`) not read yet.
- Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible only).
Closing the open-side issues: 10–20 h with users' files.
### Illustrator's private editing data
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its 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). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its 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). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
## Format by format
@@ -78,4 +80,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Spot colours defined in Lab keep their Lab values, from the editing data and from a PDF part whose ink alternate is an ICC Lab profile (#1032) |
| 2026-10-11 | minor | `.ai` in PostScript form opens through its layers, also without its prolog (#1027) |
| 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) |
| 2026-10-10 | major | First checklist; moved the `.ai` editing-data scope from the ROADMAP's "Out of scope" here; added the user-reported `.ai` failures |
+10 -4
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (moved from the ROADMAP's "Where we're lacking", re-ranked, added file-format, stability, hardware, localization, ecosystem and AI gaps) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
@@ -55,8 +55,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead
of 14; #868: round trip with Illustrator 2018 loses layers and groups), guides and non-printing construction
lines (#779), open speed on large files (#758), legacy `.ai` without the prolog, live effects, brushes, symbols
and pattern fills as live objects (#637). Writing Illustrator's own editing data is out of scope
lines (#779), open speed on large files (#758), legacy type in `.ai` files in PostScript form (those without
their prolog open since #1027), live effects, brushes and symbols as live objects (#637; pattern fills and global colours are read since #1025). Writing Illustrator's own editing data is out of scope
(undocumented); Save As `.ai` stays PDF-compatible.
- **Evidence:** the issues above; 28 of the 350 issues are about `.ai`/EPS (about 22 people, the most common single
format theme), 20 fixed, and the 8 open ones are about structure (layers, guides, speed, live objects), not missing
@@ -86,6 +86,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G11 Correctness bugs in core tools
- **Done:** scaled text's size and horizontal-scale controls account for object/group transforms (#1034),
including exact sizes across grouped labels and resetting horizontal stretch while keeping editable text.
- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Transform effect dialog's invisible checkboxes and missing options (#885);
PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834).
- **Impact:** wrong output in everyday work.
@@ -96,7 +98,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Missing:** hyphenation and justification options, Optical Margin Alignment (type core); CJK vertical
composition (ruby, mojikumi sets and dialog, proportional vertical metrics #966, kinsoku settings #633, manual
tate-chu-yoko), Middle Eastern features beyond bidi, variable font axes, spell check with an open dictionary,
Touch Type, Retype, Snap to Glyph. Itemized in [type-parity.md](type-parity.md).
Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement).
Itemized in [type-parity.md](type-parity.md).
- **Impact:** professional typography and Japanese/Chinese publishing.
- **Estimate:** 34–47 h (type core 14–19 h blocks beta).
@@ -200,6 +203,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | #1034: corrected scaled type controls; optional group font-size preservation remains separate. Estimates unchanged at this rounding. |
| 2026-10-11 | minor | G9: legacy `.ai` without the prolog opens with its layers (#1027) |
| 2026-10-11 | minor | G9: `.ai` pattern fills and global colours read from the editing data (#1025) |
| 2026-10-10 | minor | G3: initial Revolve landed (#846), 45–75 h left |
| 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back |
| 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none |
+14 -1
View File
@@ -284,7 +284,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | |
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. |
With `mods.shift`, a marquee dragged with the Selection tool toggles the objects it reaches (the selected ones leave
the selection, the others join it, the rest stays), and one dragged with Direct or Group Selection toggles the anchors
@@ -2627,6 +2627,19 @@ without ideographs uses its em box. One undo step.
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"charAlign":"icfTop"}}}
```
## Proportional Metrics
`text.setFormat {proportionalMetrics: true}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end,
proportionalMetrics}` (a range) set full-width glyphs on the proportional widths the font gives them with the
OpenType `palt` feature; glyphs the font doesn't re-space keep their full width. Line-end Punctuation Half Width then takes nothing more off the punctuation `palt` re-spaced (the opening
bracket at a line's start, consecutive punctuation, the closing mark at a line's end); punctuation left full width is
trimmed as before. Off by default and saved only when on. Horizontal type only for now: vertical type sets as before.
One undo step.
```json
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"proportionalMetrics":true}}}
```
## New type in a Japanese interface
While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em
+7 -1
View File
@@ -1,7 +1,7 @@
# Releasing VectorCraft
Every push to the `release` branch runs `.github/workflows/release.yml`. The workflow builds
installers for macOS, Windows, Linux (including Flatpak bundles) and FreeBSD, plus the web build, and creates or updates a
installers for macOS, Windows, Linux (including Flatpak bundles and a RISC-V tarball) and FreeBSD, plus the web build, and creates or updates a
**draft** GitHub Release named `VectorCraft v<version>`. Nobody sees a draft until a maintainer
publishes it.
@@ -50,6 +50,7 @@ and no draft release is created.
| Windows on ARM64 | `vectorcraft-<v>-windows-arm64.msi`, `vectorcraft-<v>-windows-arm64-portable.zip` | `windows-latest` (cross-compiled) |
| Linux x86_64 | `vectorcraft-<v>-linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` |
| Linux aarch64 | `vectorcraft-<v>-linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` |
| Linux riscv64 | `vectorcraft-<v>-linux-riscv64.tar.gz` (cross-compiled, glibc >= 2.39; CLI smoke-tested under QEMU) | `ubuntu-24.04` |
| Linux AppImage updates | `vectorcraft-<v>-linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImages |
| Flatpak x86_64, aarch64 | `vectorcraft-<v>-linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) |
| FreeBSD 14 x86_64 | `vectorcraft-<v>-freebsd-x86_64.tar.gz` | a FreeBSD 14.3 VM on `ubuntu-latest` |
@@ -144,6 +145,11 @@ glibc 2.35 or newer: Ubuntu 22.04+, Debian 12+, Fedora 36+ and RHEL 10. Moving t
image raises that floor, so do it deliberately. After packaging, the job prints the `.deb`'s
metadata and contents, runs `ldd` on the binary and runs each AppImage with `--version`.
**RISC-V.** The `linux-riscv64` job cross-compiles `riscv64gc-unknown-linux-gnu` on `ubuntu-24.04`
against riscv64 libraries from ports.ubuntu.com (so the floor there is glibc 2.39) and runs
`package.sh --formats tar` with `CROSS_ARCH`, `CROSS_TARGET`, `CROSS_COMPILE` and
`EMULATOR=qemu-riscv64` set: a `.tar.gz` only, with `vectorcraft-cli --version` run under QEMU.
**AppImage updates.** Each AppImage embeds the update information
`gh-releases-zsync|storytold|vectorcraft|latest|vectorcraft-*-linux-<arch>.AppImage.zsync`
(appimagetool `-u`), and `package.sh` writes the matching `.zsync` beside it when `zsyncmake` (the
+3 -2
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (added the Alpha gate table; earlier the same day: moved the milestone table and direction here from ROADMAP.md, added Current focus) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (M7: accurate character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and
[target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5
@@ -54,7 +54,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
| 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; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
| 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, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
| 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 |
@@ -79,6 +79,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | M7: document-point character sizes, leading and baseline shift, and resettable horizontal stretching after object/group scaling (#1034). Optional group font-size preservation remains separate; scores/hours unchanged. |
| 2026-10-10 | minor | M9 row from main: initial Revolve |
| 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha |
| 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list |
+9 -7
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (#1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
@@ -76,7 +76,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Live vector effects | 5 | 85% | Outline Object, Effect › Rasterize, Pathfinder Hard Mix/Soft Mix/Trap, SVG Filters, the Transform effect's missing options (#885) | 6–10 | measured count 44/50, depth estimated |
| Raster effects (Effect Gallery) | 4 | 48% | 29 Photoshop-style filters (Artistic, Sketch), the Effect Gallery dialog, Load Texture | 24–40 | measured count 28/57 |
| 3D and Materials (and Turntable) | 4 | ~8% | initial live Revolve done (flat shading, one light, no caps, approximate visibility for intersecting surfaces); Extrude & Bevel, Inflate, Rotate, materials, Turntable views, 3D export | 50–85 | estimated (Revolve landed after the first measure, #846) |
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, see [type-parity.md](type-parity.md) | 14–19 | estimated |
| Type core | 9 | 80% | hyphenation and justification options, Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated |
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi, proportional vertical metrics), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 85% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape | 6–10 | estimated (lowered from 88%: target moved) |
| Image Trace, graphs, image tools | 3 | 75% | graph Design/Column/Marker, Image Trace Create Strokes depth (Create Object Mosaic is done) | 5–8 | estimated (raised from 72%) |
@@ -305,7 +305,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Glass's and Texturizer's Load Texture and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 19–31 |
| 3D and Materials | 4 | ~8% | Initial Revolve only, with simple lighting and Revolve-specific visible-surface expansion; Extrude & Bevel, Inflate, Rotate, caps, materials, shadows and more complete visibility remain | 45–75 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034) | 14–19 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 72% | graph Design/Column/Marker artwork (series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
@@ -375,7 +375,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it).
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into the default tabbed group it doesn't belong to, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set.
- 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, German, Spanish, French, Italian, Russian and Ukrainian 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 Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
@@ -411,15 +411,16 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **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, the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size), and (#812) the Flare tool its Flare Tool Options, the Artboard tool the active artboard's Artboard Options, the graph tools Graph Type for the selected graph, the Symbolism tools the Symbolism Tools Options (Diameter, Intensity, Symbol Set Density) and the Magic Wand tool its panel.
- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock.
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies) and a dimmed copy of the row under the pointer (dropped on the trash rows are deleted, on New Layer duplicated, as in Illustrator), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets, each with a chip of its colour, or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. A document opens with only its top-level layers open; the rows left open are saved in the native file (nothing is written for the default) and reopen that way. Only the rows scrolled into view are laid out, from a row list made once per change: a layer of 5,000 groups of 10 paths, all open, draws the panel in 0.4 ms a frame instead of about 29 s (#638).
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them).
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends are uncapped, shading is flat with one light, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)).
- 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), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). 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). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and Line truncated
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) 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, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian 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 applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), 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 line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boLine truncated
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) 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, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); German, Spanish, French, Italian, Japanese, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian 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 applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), 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 line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type, and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (vertical type's `vpal` 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.
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
@@ -457,6 +458,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%): 16 core features weighted, depth ~91%, discounted for launch, discoverability and files people send |
| 2026-10-10 | minor | Second look at ready for real work at the owner's request: 50% → 55% full Illustrator, and a new mainstream-illustrator figure of 65%. Why: the old 25-point discount was a judgement with no breakdown. Now each discount is written down and checked against the tracker: 86% of 79 core-drawing issues closed, 20 of 28 `.ai`/EPS issues fixed and the open ones about structure, praise from 15 people, no reports of switching; dimensions a typical illustrator rarely hits are taken out of readiness. Feature breadth, depth and hours unchanged |
| 2026-10-10 | minor | Stage checked against the core-workflow gate: passes, stays alpha |
| 2026-10-10 | minor | #1034: accurate transformed character controls; type-core score/hours and headline estimates unchanged at this rounding. |
| 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file |
| 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones |
| 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate |
+4 -1
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first checklist, split out of the parity table's two type rows) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (document-point character controls after object/group scaling, #1034) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -23,6 +23,8 @@ against Illustrator's public documentation; **estimated** unless noted.
| Threaded text, Fit Headline, Text Wrap | done | |
| Type on a Path effects and options; brackets | done | #429 |
| Character and Paragraph panels, styles with overrides | done | mixed-value display for differing paragraphs missing |
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference unused (#394) |
| Font menu: samples, live preview, filters, favourites; font matching by any name; Find Font | done | #339 |
| Variable fonts | partial | named instances as styles; axis sliders missing |
@@ -48,4 +50,5 @@ against Illustrator's public documentation; **estimated** unless noted.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | minor | #1034: transformed character controls and regression coverage; preserving group font sizes remains a separate enhancement. Scores/hours unchanged at this rounding. |
| 2026-10-10 | major | First checklist, split out of the parity table's Type core and Type advanced rows |
+3 -1
View File
@@ -90,7 +90,8 @@ tools, never checked side by side) isn't captured row by row.
| Snapping panel/popover consolidating the snap options (30.0) | missing | |
| Customisable rotation snap angle (#973) | missing | Illustrator: Constrain Angle and construction angles only |
| Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451 |
| Ruler guides as Layers rows; Alt swaps a ruler guide's orientation while dragging | missing | |
| Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer |
| Alt swaps a ruler guide's orientation while dragging | missing | |
| Global and video rulers | missing | menu stubs |
## Transform handles, bounding box, Free Transform
@@ -143,6 +144,7 @@ Layers panel.
|---|---|---|
| ~40 panels with ≡ menus; dock, icon column, collapse, tabs | done | 51 panel modules incl. tests |
| Panels float inside the window, stack, dock back | done | #495 |
| Floating groups stack into sets; sets dock as extra dock columns (a multi-column dock); groups drop anywhere in a set or column, or onto its tabs; a column taller than the window scrolls | done | `window.panel.float {below, above}`, `window.panel.dock {column}` |
| Panels outside the app window (another monitor) | missing | single OS window |
| Properties panel per context (every selection type's Quick Actions) | partial | |
| Contextual task bar | done | |
+20 -7
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Build and package VectorCraft for Linux (<arch> is x86_64 or aarch64):
# Build and package VectorCraft for Linux (<arch> is x86_64 or aarch64, or riscv64 when cross-compiling):
#
# $DIST/vectorcraft-<version>-linux-<arch>.AppImage any distro with glibc >= the build host's
# $DIST/vectorcraft-<version>-linux-<arch>.AppImage.zsync delta updates (needs zsyncmake)
@@ -9,6 +9,10 @@
#
# Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"]
#
# Cross-compiling (release.yml's linux-riscv64 job): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust
# target), CROSS_COMPILE (the binutils prefix, for strip) and EMULATOR (e.g. qemu-riscv64, to
# smoke-test the CLI), and pass --formats tar.
#
# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage
# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04,
# glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli,
@@ -25,13 +29,14 @@ while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
-h | --help) sed -n '2,19p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
ARCH="$(uname -m)"
ARCH="${CROSS_ARCH:-$(uname -m)}"
case "$ARCH" in
riscv64) DEB_ARCH=riscv64 ;;
x86_64) DEB_ARCH=amd64 ;;
aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
@@ -42,9 +47,13 @@ BASENAME="vectorcraft-$VERSION-linux-$ARCH"
echo "==> VectorCraft $VERSION for Linux $ARCH ($FORMATS)"
if [ "$SKIP_BUILD" = 0 ]; then
(cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli)
if [ -n "${CROSS_TARGET:-}" ]; then
(cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli --target "$CROSS_TARGET")
else
(cd "$ROOT" && cargo build --release --locked -p vectorcraft -p vectorcraft-cli)
fi
fi
BIN="$CARGO_TARGET_DIR/release"
BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release"
WORK="$CARGO_TARGET_DIR/linux-package"
STAGE="$WORK/root"
rm -rf "$WORK"
@@ -52,7 +61,7 @@ rm -rf "$WORK"
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
install -Dm755 "$BIN/vectorcraft" "$STAGE/usr/bin/vectorcraft"
install -Dm755 "$BIN/vectorcraft-cli" "$STAGE/usr/bin/vectorcraft-cli"
strip "$STAGE/usr/bin/vectorcraft" "$STAGE/usr/bin/vectorcraft-cli" 2>/dev/null || true
"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/vectorcraft" "$STAGE/usr/bin/vectorcraft-cli" 2>/dev/null || true
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
mkdir -p "$STAGE/usr/share/metainfo"
@@ -135,6 +144,10 @@ if has appimage; then
fi
fi
"$STAGE/usr/bin/vectorcraft-cli" --version
if [ -z "${CROSS_TARGET:-}" ]; then
"$STAGE/usr/bin/vectorcraft-cli" --version
elif [ -n "${EMULATOR:-}" ]; then
"$EMULATOR" "$STAGE/usr/bin/vectorcraft-cli" --version
fi
echo "==> done"
ls -lh "$DIST"