Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 ad3e326d1d Fix #419: PDF/.ai art past the layer scan's limit never shows a hidden layer
The import walks each page's content to learn which optional content
group each marked-content sequence belongs to, and stopped after 2,000,000
operators. Past that, art went to the page's "Page N" layer, visible and
printable, so a layer that was off (or didn't print) showed and printed;
an Illustrator drawing of about a million paths lost 6 of its 15 layers.

- The walk now reads up to 64 million operators a page (it reads about
  15 million a second, twice as fast as the interpreter draws them; the
  issue's 2.1-million-operator file imports in 3 s, a 10-million one in
  15 s). Marked-content tags are kept as 64-bit hashes rather than byte
  vectors, and at most 4 million a page, so the walk's memory stays
  bounded on hostile input.
- When the walk is cut short (or the interpreter and the walk otherwise
  part) and some group is off or doesn't print, art in marked content
  whose group can't be told goes to a hidden, non-printing "Page N
  (unsorted)" layer instead of the page's layer, and the warning says
  so (and that the page was too large to read, when it was). Art drawn
  past the limit inside a group already open stays in its group; art
  outside any group stays on the page's layer. Files whose groups all
  show and print keep the old fallback.

Tests (with the limit lowered): a hidden, non-printing group past the
limit lands on the hidden unsorted layer while the open group keeps its
art and the page's art stays shown; with every group shown, art goes to
the page's layer as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:30:07 +07:00
bflatasticandClaude Opus 5.5 55543da0cb Fix #420: SVG import keeps clipped layers as layers with a clipping mask
A top-level <g id> becomes a layer only when it was plain, so an Inkscape
layer (or our own export of a layer with a clipping mask) carrying a
clip-path came in as a clip group, and a file whose layers were all
clipped to one frame opened as one "Layer 1" of clip groups with the
layer names lost.

A top-level group that does nothing but clip its art (no mask, filter,
opacity or blend) is now a layer too: its clip path becomes the layer's
clipping mask (the clipping path first, as Make Clipping Mask in the
Layers panel leaves it), a clip path clipped in turn nesting a clip
group inside the layer as before. The clip building is shared with clip
groups. Top-level text that joins a clipping layer (before or after it)
isn't in its clip: the mask then becomes a clip group inside the layer
and the text sits beside it. A clip path with no shape leaves the layer
empty.

Tests: the issue's Inkscape layers (names kept, each a clipping layer,
drawn as resvg draws the SVG), a nested clip on a layer, the export ->
import round trip of clipping layers, and text joining clipping layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:29:38 +07:00
BFlat 8b2be144cc Merge pull request #427 from storytold/fix/direct-anchor-snap
Direct Selection: dragged anchors snap to points and smart guides
2026-10-08 10:26:00 +07:00
bflatasticandClaude Opus 5.5 d1536bc355 Direct Selection: dragged anchors snap to points and smart guides
Dragging direct-selected anchors never snapped: the anchor drag sent the raw
pointer delta. Now the anchor pressed on (or the pointer on a straight segment)
snaps and the other selected anchors follow by the same delta:

- Smart Guides: onto other anchors (including the anchors of its own path that
  stay put), onto path segments ("path", new segment targets gathered only for
  anchor drags, lines exactly on straight segments), else into line with
  anchors, edges, centres and artboards ("align"). The anchors that move, their
  segments and the reshaped paths' bounds are no targets, so an anchor can't
  snap to itself. Targets are gathered once when the drag begins, capped.
- Smart Guides off: Snap to Point pulls it onto anchors and ruler guides within
  its distance; Snap to Grid / Snap to Pixel apply as for drawn points.
- Shift keeps the 45-degree constraint unsnapped.

Group Selection and Direct Selection's whole-object moves now snap like the
Selection tool: its move snapping moved into a shared `MoveSnap`, and
`snap_with` into a reusable `PointSnap` that caches its targets per drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:25:07 +07:00
BFlat 2776e91dc6 Merge pull request #417 from imachus/pr/ja-line-start-bracket
Type: set an opening bracket flush at the start of a wrapped line
2026-10-08 10:17:20 +07:00
bflatasticandClaude Opus 5.5 89c9b82955 Type: clarify what mojikumi still lacks in the ROADMAP (review of #417)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:10:00 +07:00
imachusandClaude Opus 5.5 908d966579 Type: set an opening bracket flush at the start of a wrapped line
With Line-end Punctuation Half Width, an opening bracket starting a
wrapped line now loses the half-em space before it (JLREQ 3.1.5), so its
face lines up with the line's start and the line has that much more room.
A paragraph's first line keeps the bracket full width.

Also fixes upright glyphs in vertical type that lost their leading space
(consecutive opening brackets, and now the line start): they moved up the
column but also half an em across it, because the turning point moved
instead of the glyph. The tests now check the position across the line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:58:14 +07:00
BFlat ef0b61aca1 Merge pull request #422 from storytold/fix/issue-394b
Part of #394: Preferences for snapping, handles, rubber bands, pattern tiles, tints and type
2026-10-08 09:55:43 +07:00
11 changed files with 656 additions and 129 deletions

No files matched your search

+5 -5
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394) and skeleton reads with `document.find` for large documents over MCP (#416); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -124,13 +124,13 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, the Selection, Direct Selection and Group Selection tools the Move dialog (Enter opens these seven tools' dialogs too), Gradient, Eyedropper, Blend and the Liquify tools their panels or options, and the Pencil, Paintbrush, Smooth, Blob Brush and Eraser tools their Tool Options (Fidelity, fill, the tolerances, the brush size).
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
- **Drawing aids:** Smart Guides and snapping (moving, and resizing by the bounding-box handles: the dragged edges align with the other objects; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (Shift snaps them to the ruler's ticks) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the art's edges, centres and anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles: the dragged edges align with the other objects; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), guides dragged out of the rulers (Shift snaps them to the ruler's ticks) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the art's edges, centres and anchors; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), and Draw Normal / Behind / Inside modes.
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it.
- **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.
- 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), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline; Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline; Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; Alt deletes their pieces; filled open paths count as closed), Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
@@ -147,7 +147,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export.
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
@@ -201,7 +201,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 79% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — hanging punctuation, line-start brackets, kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), hanging punctuation, kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko, Character Alignment on the ICF top and bottom), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
+25 -10
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};
use crate::import_scan::{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, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
@@ -32,6 +32,9 @@ const TEXT_OUTLINES: &str = "<Text Outlines>";
const MAX_NESTED: u32 = 8;
/// A tiling pattern read this many times with the same art is taken to always draw it.
const PATTERN_REUSE: u32 = 16;
/// Stands for the group of art whose group couldn't be told (see [`Builder::unsure`]): it goes
/// to a hidden, non-printing layer of its page.
const UNSORTED: usize = usize::MAX;
/// Import a PDF (or PDF-compatible `.ai`) with default options.
pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
@@ -146,6 +149,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
doc.layers.clear();
let taken: Vec<String> = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect();
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken.clone()), opts.text_as, route);
// The art of a group that is off (drawn as `all_on` turned it on) or doesn't print mustn't
// show or print on its page's layer.
b.unsure = route && ocgs.list.iter().any(|g| (all_on && !g.on) || !g.print);
b.taken = taken;
b.warnings = notes;
let cache = InterpreterCache::new();
@@ -198,19 +204,22 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
show_center_mark: false,
show_cross_hairs: false,
});
let page_layer = |b: &mut Builder<'_>, art: Vec<Arc<Node>>| {
let mut layer = Node::layer(b.id(), &format!("Page {}", number + 1), LayerColor::Preset((i % 27) as u8));
if let NodeKind::Layer { children: c, .. } = &mut layer.kind {
*c = art;
let page_layer = |b: &mut Builder<'_>, art: Vec<Arc<Node>>, shown: bool| {
let name = if shown { format!("Page {}", number + 1) } else { format!("Page {} (unsorted)", number + 1) };
let mut layer = Node::layer(b.id(), &name, LayerColor::Preset((i % 27) as u8));
layer.visible = shown;
if let NodeKind::Layer { children: c, printable, .. } = &mut layer.kind {
(*c, *printable) = (art, shown);
}
Slot::Page(Box::new(layer))
};
if parts.is_empty() {
slots.push(page_layer(&mut b, vec![]));
slots.push(page_layer(&mut b, vec![], true));
}
for (key, art) in parts {
match key {
None => slots.push(page_layer(&mut b, art)),
None => slots.push(page_layer(&mut b, art, true)),
Some(UNSORTED) => slots.push(page_layer(&mut b, art, false)),
Some(g) => {
let into = group_art.entry(g).or_insert_with(|| {
place_group(g, &ocgs, &mut slots, &mut nesting);
@@ -542,6 +551,9 @@ struct Builder<'p> {
aligned: bool,
/// Whether optional content groups become layers.
route: bool,
/// Some groups are hidden or don't print: art in marked content whose group can't be told
/// (once the scan and the interpreter part) is [`UNSORTED`] rather than the page's.
unsure: bool,
/// The marked-content sequences open: the group each marks.
marked: Vec<Option<usize>>,
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
@@ -678,6 +690,7 @@ impl<'p> Builder<'p> {
group_at: 0,
aligned: true,
route,
unsure: false,
marked: vec![],
pending: false,
nested: 0,
@@ -1521,13 +1534,15 @@ impl<'a> Device<'a> for Builder<'_> {
let at = self.tag_at;
self.tag_at += 1;
let group = match self.scan.tags.get(at) {
Some((t, g)) if self.aligned && t.as_slice() == tag => *g,
Some((t, g)) if self.aligned && *t == tag_key(tag) => *g,
_ => {
if self.route && self.aligned {
self.aligned = false;
self.warn("some art couldn't be told apart by layer and went to its page's layer");
let why = if self.scan.cut { "a page draws more than can be read for its layers: " } else { "" };
let went = if self.unsure { "a hidden, non-printing layer of its page" } else { "its page's layer" };
self.warn(&format!("{why}some art couldn't be told apart by layer and went to {went}"));
}
None
self.unsure.then_some(UNSORTED)
}
}
.filter(|_| self.route);
+41 -8
View File
@@ -21,8 +21,14 @@ use hayro_syntax::page::{Page, Resources};
/// The interpreter's limit on nested form XObjects.
const MAX_DEPTH: u32 = 50;
/// The most content operators walked per page: past it the rest of the page isn't told apart.
const MAX_OPS: usize = 2_000_000;
/// The most content operators walked per page (a guard against forms drawn within each other
/// many times over): the art past it can't be told apart by group. A drawing of a million paths
/// takes a few million; the walk reads about 15 million a second, twice as fast as the
/// interpreter draws them.
const MAX_OPS: usize = 64_000_000;
/// The most marked-content sequences noted per page (24 bytes each): past it the rest of the
/// page isn't told apart either.
const MAX_TAGS: usize = 4_000_000;
/// The most groups read.
const MAX_GROUPS: usize = 10_000;
/// The deepest layers nest in the configuration's `/Order`.
@@ -206,10 +212,35 @@ pub(crate) fn text_string(b: &[u8]) -> String {
/// One page as the interpreter walks it.
#[derive(Default)]
pub(crate) struct Scan {
/// Each marked-content sequence: its tag and the group (index into [`Ocgs::list`]) it marks.
pub tags: Vec<(Vec<u8>, Option<usize>)>,
/// Each marked-content sequence: its tag ([`tag_key`]) and the group (index into
/// [`Ocgs::list`]) it marks.
pub tags: Vec<(u64, Option<usize>)>,
/// Each form transparency group: isolated, knockout.
pub groups: Vec<(bool, bool)>,
/// The walk stopped at [`MAX_OPS`] or [`MAX_TAGS`]: the rest of the page isn't told apart.
pub cut: bool,
}
/// A marked-content tag as [`Scan::tags`] keeps it: its FNV-1a hash (the tags are only compared,
/// and a page can have millions).
pub(crate) fn tag_key(tag: &[u8]) -> u64 {
tag.iter().fold(0xcbf2_9ce4_8422_2325, |h, &b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
}
/// [`MAX_OPS`], lowered by tests that need a page past it.
#[cfg(not(test))]
fn max_ops() -> usize {
MAX_OPS
}
#[cfg(test)]
thread_local! {
pub(crate) static TEST_MAX_OPS: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_OPS) };
}
#[cfg(test)]
fn max_ops() -> usize {
TEST_MAX_OPS.with(std::cell::Cell::get)
}
struct Walker<'w, 'p> {
@@ -220,6 +251,7 @@ struct Walker<'w, 'p> {
fonts: &'w mut HashMap<u128, String>,
visible: Vec<bool>,
ops: usize,
max_ops: usize,
out: Scan,
/// A form XObject hidden by its own optional content was met.
hidden_form: bool,
@@ -273,7 +305,8 @@ impl Walker<'_, '_> {
self.note_fonts(res);
while let Some(op) = iter.next() {
self.ops += 1;
if self.ops > MAX_OPS {
if self.ops > self.max_ops || self.out.tags.len() >= MAX_TAGS {
self.out.cut = true;
return;
}
match op {
@@ -296,11 +329,11 @@ impl Walker<'_, '_> {
None
}
};
self.out.tags.push((bdc.0.to_vec(), group));
self.out.tags.push((tag_key(bdc.0.as_ref()), group));
}
TypedInstruction::BeginMarkedContent(bmc) => {
self.begin(true);
self.out.tags.push((bmc.0.to_vec(), None));
self.out.tags.push((tag_key(bmc.0.as_ref()), None));
}
TypedInstruction::EndMarkedContent(_) => {
self.visible.pop();
@@ -347,7 +380,7 @@ pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap<u128, String>) -> 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, out: Scan::default(), hidden_form: false };
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);
w
}
+55 -1
View File
@@ -1,5 +1,6 @@
//! Optional content groups become layers: name, visibility (the default configuration's or the
//! view state's), print state, lock and nesting; art that is off comes in as a hidden layer.
//! view state's), print state, lock and nesting; art that is off comes in as a hidden layer, and
//! art past what is read of a huge page never shows or prints a hidden layer.
use vectorcraft_color::Color;
use vectorcraft_doc::{Document, Node, NodeKind};
@@ -298,3 +299,56 @@ fn thousands_of_layers_hidden_ones_included_all_come_in() {
assert_eq!(d.layers.len(), N);
assert!(d.layers.iter().enumerate().all(|(i, l)| l.visible == (i % 2 == 1) && l.children().is_some_and(|c| c.len() == 1)));
}
/// Import `bytes` with the scan's operator limit lowered to `n`.
fn import_with_max_ops(bytes: &[u8], n: usize) -> ImportReport {
let old = crate::import_scan::TEST_MAX_OPS.replace(n);
let r = import_with_report(bytes, &ImportOptions::default());
crate::import_scan::TEST_MAX_OPS.set(old);
r.unwrap()
}
#[test]
fn art_past_the_scan_limit_never_shows_a_hidden_layer() {
// "Shapes" draws 100 operators before its square, then "Hidden" (off, not printed) its own.
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let filler = "q Q ".repeat(50);
let page = PdfPage {
resources: format!("/Properties << /MC0 {} 0 R /MC1 {} 0 R >>", ids[0], ids[1]),
..PdfPage::new(
100.0,
100.0,
&format!("/OC /MC0 BDC {filler}1 0 0 rg 10 10 30 30 re f EMC /OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC 0 1 0 rg 0 0 5 5 re f"),
)
};
let bytes = pdf_with_catalog(&[page], &[&objs[0], &objs[1]], &catalog, None);
// Within the limit, every group is told apart.
assert_eq!(names(&import(&bytes).unwrap()), ["Shapes", "Hidden", "Page 1"]);
let r = import_with_max_ops(&bytes, 60);
let d = &r.document;
assert_eq!(names(d), ["Shapes", "Page 1 (unsorted)", "Page 1"], "{:?}", r.warnings);
// The art drawn past the limit in a group already open stays in it.
assert!(d.layers[0].visible);
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 0.0, 0.0)]);
// The hidden group's art, past it, can't be told apart: it neither shows nor prints.
let unsorted = &d.layers[1];
assert!(!unsorted.visible && !printable(unsorted));
assert_eq!(colors(unsorted), [Color::rgb(0.0, 0.0, 1.0)]);
// Art outside any group is the page's, as before.
assert!(d.layers[2].visible && printable(&d.layers[2]));
assert_eq!(colors(&d.layers[2]), [Color::rgb(0.0, 1.0, 0.0)]);
assert!(r.warnings.iter().any(|w| w.contains("more than can be read") && w.contains("hidden, non-printing")), "{:?}", r.warnings);
}
#[test]
fn art_past_the_scan_limit_of_shown_layers_stays_shown() {
// Every group shows and prints: what can't be told apart goes to the page's layer, shown.
let bytes = three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], "");
let r = import_with_max_ops(&bytes, 4);
let d = &r.document;
assert_eq!(names(d), ["A", "Page 1"], "{:?}", r.warnings);
assert_eq!(colors(&d.layers[0]), [Color::rgb(1.0, 0.0, 0.0)]);
assert!(d.layers[1].visible && printable(&d.layers[1]));
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]);
assert!(r.warnings.iter().any(|w| w.contains("went to its page's layer")), "{:?}", r.warnings);
}
+74 -31
View File
@@ -83,8 +83,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
}
// Top-level `<g id>` elements become layers (as in the reference app); otherwise all art goes
// into "Layer 1". Top-level text joins the layer below it (the first layer if none is).
let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && is_plain(g) && im.text_slot(g).is_none());
// into "Layer 1". A clip path on one (as on an Inkscape layer, or our clipping layers) makes it
// a clipping layer. Top-level text joins the layer below it (the first layer if none is).
let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && only_clips(g) && im.text_slot(g).is_none());
let is_text = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if im.text_slot(g).is_some());
let layer_mode = top.children().iter().any(|c| is_layer(&im, c)) && top.children().iter().all(|c| is_layer(&im, c) || is_text(&im, c));
if layer_mode {
@@ -93,22 +94,35 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
for c in top.children() {
let usvg::Node::Group(g) = c else { continue };
if !is_layer(&im, c) {
let n = im.node(c, base).map(Arc::new);
match im.doc.layers.last_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
Some(ch) => ch.extend(n),
let n: Vec<_> = im.node(c, base).map(Arc::new).into_iter().collect();
match im.doc.layers.pop() {
Some(mut l) => {
im.add_unclipped(Arc::make_mut(&mut l), usize::MAX, n);
im.doc.layers.push(l);
}
None => loose.extend(n),
}
continue;
}
let mut children = std::mem::take(&mut loose);
children.extend(im.children(g, base * aff(g.transform())));
let ts = base * aff(g.transform());
let name = im.name_of(g.id()).to_string();
let mut art = im.children(g, ts);
// A clip path with no shape shows nothing of the layer: it stays, empty.
let clip = match g.clip_path() {
Some(cp) => {
art = im.clipped(cp, ts, art, &format!("layer '{name}'")).unwrap_or_default();
!art.is_empty()
}
None => false,
};
let id = im.doc.alloc_id();
let preset = (im.doc.layers.len() % vectorcraft_doc::LAYER_COLORS.len()) as u8;
let name = im.name_of(g.id()).to_string();
let mut l = Node::layer(id, &name, LayerColor::Preset(preset));
l.set_clips(clip);
if let Some(ch) = l.children_mut() {
*ch = children;
*ch = art;
}
im.add_unclipped(&mut l, 0, std::mem::take(&mut loose));
l.visible = !im.hidden.contains(g.id());
im.doc.layers.push(Arc::new(l));
}
@@ -472,7 +486,12 @@ fn aff(t: usvg::Transform) -> Affine {
}
fn is_plain(g: &usvg::Group) -> bool {
g.clip_path().is_none() && g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal
g.clip_path().is_none() && only_clips(g)
}
/// Does group `g` leave its art as it is, but for a clip path (no mask, filter, opacity or blend)?
fn only_clips(g: &usvg::Group) -> bool {
g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal
}
fn blend(b: usvg::BlendMode) -> BlendMode {
@@ -758,27 +777,8 @@ impl Importer {
inner.appearance.effects = effects.clone();
children = vec![Arc::new(inner)];
}
// A clip path clipped in turn (`<clipPath clip-path>`): one clip group in another, so
// the clips intersect.
let mut clips = vec![self.clip_node(cp, ts)?];
let mut next = cp.clip_path();
while let Some(c) = next {
if clips.len() > MAX_CLIP_NEST {
self.warn(format!("clip paths nested more than {MAX_CLIP_NEST} deep on {label}: the deeper ones are ignored"));
break;
}
clips.push(self.clip_node(c, ts)?);
next = c.clip_path();
}
let outer = clips.pop()?;
for clip in clips {
let mut ch = vec![Arc::new(clip)];
ch.extend(children);
children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))];
}
let mut ch = vec![Arc::new(outer)];
ch.extend(children);
self.named(id, NodeKind::Group { children: ch, clip: true })
let children = self.clipped(cp, ts, children, &label)?;
self.named(id, NodeKind::Group { children, clip: true })
} else {
if children.is_empty() {
return None;
@@ -849,6 +849,49 @@ impl Importer {
Some(Box::new(mask))
}
/// `children` clipped by `cp` (on an element whose user space `ts` maps to the document): the
/// children of a clip group or clipping layer, its clipping path first. A clip path clipped in
/// turn (`<clipPath clip-path>`) nests one clip group in another, so the clips intersect.
/// `None` when the clip path has no shape (nothing shows).
fn clipped(&mut self, cp: &usvg::ClipPath, ts: Affine, mut children: Vec<Arc<Node>>, label: &str) -> Option<Vec<Arc<Node>>> {
let mut clips = vec![self.clip_node(cp, ts)?];
let mut next = cp.clip_path();
while let Some(c) = next {
if clips.len() > MAX_CLIP_NEST {
self.warn(format!("clip paths nested more than {MAX_CLIP_NEST} deep on {label}: the deeper ones are ignored"));
break;
}
clips.push(self.clip_node(c, ts)?);
next = c.clip_path();
}
let outer = clips.pop()?;
for clip in clips {
let mut ch = vec![Arc::new(clip)];
ch.extend(children);
children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))];
}
let mut ch = vec![Arc::new(outer)];
ch.extend(children);
Some(ch)
}
/// Put `art` in layer `l` at `index` (at most its end) unclipped: a clipping mask on the layer
/// becomes a clip group in it, holding the art it clipped.
fn add_unclipped(&mut self, l: &mut Node, index: usize, art: Vec<Arc<Node>>) {
if art.is_empty() {
return;
}
if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind {
*clip = false;
let clipped = std::mem::take(children);
children.push(Arc::new(self.named("", NodeKind::Group { children: clipped, clip: true })));
}
if let Some(ch) = l.children_mut() {
let i = index.min(ch.len());
ch.splice(i..i, art);
}
}
fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
let shapes = clip_shapes(cp, ts);
if shapes.is_empty() {
+74 -2
View File
@@ -1,6 +1,6 @@
//! Symbols export as one `<symbol>` def and a `<use>` per instance (instances the def can't stand
//! for get their own art), hidden layers are kept hidden when asked and come back hidden, and the
//! editing data notices edits made elsewhere.
//! for get their own art), hidden layers are kept hidden when asked and come back hidden, clipped
//! layers come in as clipping layers, and the editing data notices edits made elsewhere.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -205,6 +205,78 @@ fn hidden_layers_are_kept_hidden_when_asked() {
}
}
/// Each layer's art: clipping layer or not, and the kinds of its children.
fn layer_art(d: &Document) -> Vec<(String, bool, Vec<&'static str>)> {
d.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.clips(), l.children().unwrap().iter().map(|c| c.kind_label()).collect())).collect()
}
/// Two Inkscape layers clipped to the same window (#420), in pixels so resvg draws them at our scale.
const CLIPPED_LAYERS: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="200" height="100" viewBox="0 0 200 100">
<defs><clipPath id="window"><rect x="5" y="5" width="190" height="90"/></clipPath></defs>
<g id="CUT" inkscape:groupmode="layer" inkscape:label="Cut lines" clip-path="url(#window)"><rect x="0" y="0" width="100" height="100" fill="#f00"/></g>
<g id="HATCH" inkscape:groupmode="layer" inkscape:label="HATCH" clip-path="url(#window)"><rect x="100" y="0" width="100" height="100" fill="#00f"/></g>
</svg>"##;
#[test]
fn clipped_layers_come_in_as_clipping_layers() {
let d = import(CLIPPED_LAYERS).unwrap();
assert_eq!(
layer_art(&d),
[("Cut lines".to_string(), true, vec!["Path", "Path"]), ("HATCH".to_string(), true, vec!["Path", "Path"])],
"{:?}",
d.layers
);
for l in &d.layers {
let clip = &l.children().unwrap()[0];
assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. }) && clip.name.as_deref() == Some("window"));
assert_eq!(clip.geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 195.0, 95.0));
}
// Drawn as the SVG is: each layer cut to the window.
assert_similar(&render_artboard(&d), &resvg_render(CLIPPED_LAYERS, 200, 100), 24.0, 0.002);
// The issue's file (in points): two clipping layers, the names kept.
let pt = CLIPPED_LAYERS.replace("width=\"200\" height=\"100\"", "width=\"200pt\" height=\"100pt\"");
let d = import(&pt).unwrap();
assert_eq!(d.layers.iter().map(|l| (l.name.as_deref().unwrap(), l.clips())).collect::<Vec<_>>(), [("Cut lines", true), ("HATCH", true)]);
}
#[test]
fn clipping_layers_round_trip() {
let mut d = import(CLIPPED_LAYERS).unwrap();
// A clip path clipped in turn: the layer's own clip outermost, the other one inside it.
let nested = CLIPPED_LAYERS.replace(
"<defs>",
"<defs><clipPath id=\"left\"><rect x=\"0\" y=\"0\" width=\"60\" height=\"100\"/></clipPath><clipPath id=\"inner\" clip-path=\"url(#left)\"><rect x=\"20\" y=\"20\" width=\"160\" height=\"60\"/></clipPath>",
);
let nested = nested.replacen("url(#window)", "url(#inner)", 1);
let n = import(&nested).unwrap();
assert_eq!(layer_art(&n)[0], ("Cut lines".to_string(), true, vec!["Path", "Clip Group"]));
assert_similar(&render_artboard(&n), &resvg_render(&nested, 200, 100), 24.0, 0.002);
d.layers.push(n.layers[0].clone());
let svg = export(&d, &ExportOptions::default());
let back = import(&svg).unwrap();
assert_eq!(layer_art(&back), layer_art(&d), "{svg}");
assert_similar(&render_artboard(&back), &render_artboard(&d), 24.0, 0.002);
}
#[test]
fn text_joining_a_clipping_layer_stays_unclipped() {
// Text beside clipped layers isn't in their clip: the layer keeps its clip as a clip group.
let svg = CLIPPED_LAYERS
.replace("<g id=\"CUT\"", "<text x=\"1\" y=\"99\">Before</text><g id=\"CUT\"")
.replace("</svg>", "<text x=\"150\" y=\"99\">After</text></svg>");
let d = import(&svg).unwrap();
assert_eq!(
layer_art(&d),
[("Cut lines".to_string(), false, vec!["Type", "Clip Group"]), ("HATCH".to_string(), false, vec!["Clip Group", "Type"])],
"{:?}",
d.layers
);
for l in &d.layers {
let g = l.children().unwrap().iter().find(|c| c.clips()).unwrap();
assert_eq!(g.children().unwrap().iter().map(|c| c.kind_label()).collect::<Vec<_>>(), ["Path", "Path"]);
}
}
#[test]
fn hidden_groups_others_use_stay_hidden() {
// A display:none group a <use> refers to is a template, not a hidden layer.
+18 -5
View File
@@ -56,7 +56,7 @@ impl Ctx<'_> {
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
let src = self.db.outline(&g.face, g.gid);
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an
// upright one in vertical type by turning about a point that much higher.
// upright one in vertical type by moving it up the column once it stands upright.
let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g);
let lead = if upright { 0.0 } else { g.lead };
let local =
@@ -79,10 +79,11 @@ impl Ctx<'_> {
// after the cell, and must not push the glyph off the centre line).
let em = self.style_at(g.byte).size;
let cell = if g.adv > 0.0 { upright_cell(g) } else { advance };
m = Affine::rotate_about(
-std::f64::consts::FRAC_PI_2,
Point::new(origin.x + cell * 0.5 - g.lead, origin.y - upright_centre(g, cell, em)),
) * m;
// A leading space taken off moves it up the column after the turn (moving the
// turning point instead would move it across the column too).
m = Affine::translate((-g.lead, 0.0))
* Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em)))
* m;
}
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
@@ -969,6 +970,18 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
let est = if i < n { Metrics::of(&sg[i]) } else { pm };
let first_line = li_para == 0;
let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 };
// Mojikumi: an opening bracket starting a wrapped line is set flush with the line's
// start (the space before it goes), and the line has that much more room.
if !first_line
&& !rtl
&& para.mojikumi == Mojikumi::LineEndHalf
&& let Some(g) = sg.get_mut(i)
&& g.lead <= 0.0
&& let Some(h) = punct_half(g, Punct::Opening)
{
g.adv -= h;
g.lead += h;
}
// Place, break, then settle the baseline on the line's real metrics (moving on to the
// next row/column if it no longer fits).
let (baseline, x0, x1, end, hyph, m) = loop {
+42
View File
@@ -325,6 +325,9 @@ fn mojikumi_halves_line_end_and_consecutive_punctuation() {
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box());
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: the second 「 is drawn half an em earlier ({moved})");
let across = |r: Rect| if vertical_type { r.x0 } else { r.y0 };
let drift = across(off.glyphs[1].outline.bounding_box()) - across(on.glyphs[1].outline.bounding_box());
assert!(drift.abs() < 0.01, "vertical {vertical_type}: and not moved across the line ({drift})");
// Text without punctuation is untouched.
assert_eq!(adv("一二", Mojikumi::LineEndHalf), adv("一二", Mojikumi::None));
}
@@ -364,3 +367,42 @@ fn mojikumi_spaces_japanese_from_latin_by_a_quarter_em() {
let off = lay("第10回", Mojikumi::None, true);
assert!(on.glyphs.iter().zip(&off.glyphs).all(|(a, b)| (a.advance - b.advance).abs() < 0.01));
}
/// Mojikumi (JLREQ 3.1.5): with Line-end Punctuation Half Width, an opening bracket starting a
/// wrapped line is set flush with the line's start (the space before it goes), giving the line half
/// an em more room; at the start of a paragraph it keeps its full width. Horizontal and vertical.
/// Needs a font with full-width Japanese punctuation (skipped without one).
#[test]
fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() {
use vectorcraft_doc::Mojikumi;
// 20 pt type in a frame three and a half ems across the lines.
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.vertical = vertical_type;
t.para.mojikumi = m;
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 70.0) } else { Rect::new(0.0, 0.0, 70.0, 200.0) };
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
layout(FontDb::global(), &t)
};
if (lay("一「", Mojikumi::None, false).glyphs[1].advance - 20.0).abs() > 2.0 {
return; // no font with full-width punctuation here
}
for vertical_type in [false, true] {
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
// 一二三 / 「四五六: the bracket can't end the first line, so it starts the second.
let (on, off) = (lay("一二三「四五六", Mojikumi::LineEndHalf, vertical_type), lay("一二三「四五六", Mojikumi::None, vertical_type));
let moved = along(off.glyphs[3].outline.bounding_box()) - along(on.glyphs[3].outline.bounding_box());
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: 「 is drawn half an em earlier ({moved})");
let across = |r: Rect| if vertical_type { r.x0 } else { r.y0 };
let drift = across(off.glyphs[3].outline.bounding_box()) - across(on.glyphs[3].outline.bounding_box());
assert!(drift.abs() < 0.01, "vertical {vertical_type}: and not moved across the line ({drift})");
assert!((on.glyphs[3].advance - 10.0).abs() < 0.01, "vertical {vertical_type}: {}", on.glyphs[3].advance);
// The half em it gave up lets 六 stay on the line.
assert_eq!(on.glyphs[6].line, on.glyphs[3].line, "vertical {vertical_type}");
assert_ne!(off.glyphs[6].line, off.glyphs[3].line, "vertical {vertical_type}");
// At the start of a paragraph the bracket keeps its full width.
let first = lay("「一」", Mojikumi::LineEndHalf, vertical_type);
assert!((first.glyphs[0].advance - 20.0).abs() < 0.01, "vertical {vertical_type}: {}", first.glyphs[0].advance);
}
}
+139 -15
View File
@@ -1,12 +1,15 @@
//! Direct Selection (A) and Group Selection tools.
//!
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
//! path's anchors on that segment, drag to move selected anchors, drag a direction handle to
//! path's anchors on that segment, drag to move selected anchors (the one pressed on snaps to
//! anchors, segments and Smart Guides, or with them off Snap to Point; Shift keeps the move at 45°
//! steps), drag a direction handle to
//! reshape (Shift keeps it at 45° steps round its anchor, Alt moves it alone; smart guides snap
//! it), marquee to select anchors, drag a live rectangle's corner widget to round its corners
//! (the selected ones when anchors are selected; Alt-click cycles their kind, double-click opens
//! the Corners dialog).
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
//! Both move objects selected as a whole as the Selection tool does, snapping alike.
//! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]).
//! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
@@ -26,16 +29,20 @@ use vectorcraft_geom::{PathData, Point, Rect};
use crate::bbox::move_delta;
use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::{PointSnap, Targets};
use crate::meshedit::MeshEdit;
use crate::rulerguide::GuideEdit;
use crate::select::{is_area_type, matrix_json};
use crate::select::{MoveSnap, is_area_type, matrix_json};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Debug)]
enum State {
Idle,
/// Dragging the selected anchors, which move alike: `grab` is where the anchor pressed on (or
/// the pointer on a segment) was, the point that snaps.
MoveAnchors {
start: Point,
grab: Point,
began: bool,
},
MoveObject {
@@ -94,8 +101,12 @@ pub struct DirectSelectionTool {
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
spine: Option<(NodeId, usize)>,
mesh: MeshEdit,
/// Smart guides of the handle being dragged.
/// Smart guides of the handle, anchors or objects being dragged.
guides: Vec<Overlay>,
/// What the dragged anchors snap to, gathered when the drag begins.
anchor_snap: Option<PointSnap>,
/// What the objects being moved snap to, gathered when the move begins.
move_snap: Option<MoveSnap>,
/// The anchor under the pointer (Highlight anchors on mouse over): its path, where it is.
hover: Option<(NodeId, Point)>,
guide: GuideEdit,
@@ -103,7 +114,17 @@ pub struct DirectSelectionTool {
impl DirectSelectionTool {
pub fn new(group: bool) -> Self {
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default(), guides: vec![], hover: None, guide: GuideEdit::default() }
Self {
group,
state: State::Idle,
spine: None,
mesh: MeshEdit::default(),
guides: vec![],
anchor_snap: None,
move_snap: None,
hover: None,
guide: GuideEdit::default(),
}
}
}
@@ -170,12 +191,12 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
v
}
/// Anchor of any visible path or area type frame under `p`, topmost first.
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
/// Anchor of any visible path or area type frame under `p`, topmost first: (id, si, ai, where it is).
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> {
let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
owners.into_iter().find_map(|id| {
let pd = cx.doc.node(id).and_then(editable_path)?;
pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, _)| (id, si, ai))
pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, a)| (id, si, ai, a.p))
})
}
@@ -310,12 +331,12 @@ impl Tool for DirectSelectionTool {
self.state = State::SpinePoint { id, anchor, from, start: p, began: false };
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
if let Some((id, si, ai, grab)) = hit_anchor(cx, p, tol) {
if cx.doc.node(id).is_some_and(is_area_type) {
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
}
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
self.state = State::MoveAnchors { start: p, began: false };
self.state = State::MoveAnchors { start: p, grab, began: false };
if ev.mods.shift {
return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))];
}
@@ -346,7 +367,7 @@ impl Tool for DirectSelectionTool {
self.state = if s.curved {
State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false }
} else {
State::MoveAnchors { start: p, began: false }
State::MoveAnchors { start: p, grab: p, began: false }
};
if selected {
return vec![];
@@ -367,16 +388,25 @@ impl Tool for DirectSelectionTool {
self.state = State::Marquee { start: p, cur: p, add: ev.mods.shift };
vec![]
}
(PointerKind::Drag, State::MoveAnchors { start, began }) => {
(PointerKind::Drag, State::MoveAnchors { start, grab, began }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Move".into()));
self.state = State::MoveAnchors { start, began: true };
self.state = State::MoveAnchors { start, grab, began: true };
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
}
let mut d = move_delta(start, p, ev.mods.shift);
// The grabbed anchor snaps and the others follow it; Shift keeps the angle instead.
self.guides.clear();
if !ev.mods.shift
&& let Some(snap) = &self.anchor_snap
{
let (q, guides) = snap.snap(cx, grab + d);
(d, self.guides) = (q - grab, guides);
}
let d = move_delta(start, p, ev.mods.shift);
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
out
}
@@ -404,8 +434,12 @@ impl Tool for DirectSelectionTool {
}
out.push(Action::Begin("Move".into()));
self.state = State::MoveObject { start, began: true };
self.move_snap = Some(MoveSnap::new(cx));
}
let mut d = move_delta(start, p, ev.mods.shift);
if let Some(snap) = &self.move_snap {
(d, self.guides) = snap.snap(cx, start, d);
}
let d = move_delta(start, p, ev.mods.shift);
out.push(Action::Preview(
"object.transform".into(),
json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(d)), "copy": ev.mods.alt}),
@@ -477,6 +511,8 @@ impl Tool for DirectSelectionTool {
| State::TypeArea { began, .. },
) => {
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.move_snap) = (None, None);
if began { vec![Action::Commit] } else { vec![] }
}
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
@@ -517,7 +553,7 @@ impl Tool for DirectSelectionTool {
if !self.group {
out.extend(frame_overlays(cx));
}
if matches!(self.state, State::Handle { .. }) {
if matches!(self.state, State::Handle { .. } | State::MoveAnchors { .. } | State::MoveObject { .. }) {
out.extend(self.guides.iter().cloned());
}
if let Some((id, p)) = self.hover.filter(|_| matches!(self.state, State::Idle)) {
@@ -793,4 +829,92 @@ mod tests {
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0));
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}");
}
/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B).
fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) {
let (mut d, a) = doc_with_rect();
let l = d.layers[0].id;
let b = d.alloc_id();
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0));
d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap();
(d, a, b)
}
/// The anchors `anchors` of path `id` direct-selected.
fn anchors_of(id: NodeId, anchors: &[AnchorRef]) -> Selection {
let mut s = Selection::default();
s.set([id]);
s.anchors.insert(id, anchors.iter().copied().collect());
s
}
/// Press at `from`, drag to `to`: the move the drag previews, and the smart guide labels shown.
fn drag(t: &mut DirectSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), mods: Mods) -> (Value, Vec<String>) {
t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(mods));
let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(mods));
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
let labels = t
.overlays(cx)
.into_iter()
.filter_map(|o| match o {
Overlay::Label { text, .. } => Some(text),
_ => None,
})
.collect();
assert_eq!(t.pointer(cx, &PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![Action::Commit]);
assert!(t.overlays(cx).iter().all(|o| !matches!(o, Overlay::Label { .. })), "the guides go with the drag");
(v.clone(), labels)
}
/// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors
/// of its own path, and with several selected the one pressed on snaps, the others following.
#[test]
fn dragged_anchors_snap_to_points_segments_and_smart_guides() {
let (d, _, b) = two_squares();
let p = paint();
let none = Mods::default();
let one = anchors_of(b, &[(0, 0)]);
let c = cx(&d, &one, &p);
let mut t = DirectSelectionTool::new(false);
// B's top-left corner, pressed 1 pt off, onto A's bottom-right one.
let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), none);
assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -100.0}), vec!["anchor".to_string()]));
// Onto A's right side.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 150.0), none);
assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -150.0}), vec!["path".to_string()]));
// In line with B's own bottom-left corner, which stays put.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none);
assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()]));
// Shift keeps the move at 45° steps, unsnapped.
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), Mods { shift: true, ..none });
let s = move_delta(Point::new(300.0, 300.0), Point::new(203.0, 199.0), true);
assert_eq!((v, labels), (json!({"dx": s.x, "dy": s.y}), vec![]));
// Two anchors: the one pressed on (B's top-right) lands on A's corner.
let two = anchors_of(b, &[(0, 0), (0, 1)]);
let c = cx(&d, &two, &p);
let (v, _) = drag(&mut t, &c, (400.0, 300.0), (202.0, 198.0), none);
assert_eq!(v, json!({"dx": -200.0, "dy": -100.0}));
// Smart Guides off: Snap to Point pulls within its distance (2 px) only.
let c = ToolContext { smart_guides: false, ..cx(&d, &one, &p) };
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -100.0, "dy": -100.0}));
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), none).0, json!({"dx": -97.0, "dy": -101.0}));
let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &one, &p) };
assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -99.0, "dy": -101.0}));
}
/// Group Selection (and Direct Selection on a whole path) moves objects snapping as the
/// Selection tool does: B's left side lines up with A's right one.
#[test]
fn moved_objects_snap_with_smart_guides() {
let (d, _, b) = two_squares();
let p = paint();
let mut s = Selection::default();
s.set([b]);
let c = cx(&d, &s, &p);
for group in [true, false] {
let mut t = DirectSelectionTool::new(group);
let (v, _) = drag(&mut t, &c, (350.0, 350.0), (253.0, 351.0), Mods::default());
assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, -100.0, 1.0]), "group: {group}");
}
}
}
+131 -20
View File
@@ -1,9 +1,11 @@
//! Smart Guides: snapping to anchors, object bounds (edges/centres), artboards, with the magenta
//! construction lines and labels Illustrator users expect.
//! Smart Guides: snapping to anchors, path segments (for dragged anchors), object bounds
//! (edges/centres), artboards, with the magenta construction lines and labels Illustrator users
//! expect.
use vectorcraft_doc::hit::{HitOptions, hit_test};
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox, Selection};
use vectorcraft_geom::kurbo::ParamCurveNearest;
use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, SubPath, Vec2};
use crate::bbox::Handle;
use crate::{Overlay, ToolContext};
@@ -39,20 +41,67 @@ pub struct Targets {
points: Vec<(Point, Kind)>,
xs: Vec<(f64, Point, Kind)>,
ys: Vec<(f64, Point, Kind)>,
/// Path segments and their control bounds, which a point lands on ("path"): gathered only for
/// dragged anchors ([`Self::for_anchor_drag`]).
segments: Vec<(Rect, PathSeg)>,
}
/// How many anchors, and how many segments, the targets gather at most.
const BUDGET: usize = 20_000;
impl Targets {
pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
Self::collect_inner(doc, exclude, None, visible)
Self::collect_inner(doc, exclude, None, visible, false)
}
/// Targets for dragging artboard `index`: everything except that artboard and `exclude` (the
/// art that moves along with it).
pub fn for_artboard(doc: &Document, index: usize, exclude: &[NodeId]) -> Self {
Self::collect_inner(doc, exclude, Some(index), None)
Self::collect_inner(doc, exclude, Some(index), None, false)
}
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>) -> Self {
/// Targets for dragging the direct-selected anchors of `sel` (and its objects selected as a
/// whole): the other art, its path segments too, and the anchors and segments that stay put on
/// the paths being reshaped (their bounds move along, so they are no targets).
pub fn for_anchor_drag(doc: &Document, sel: &Selection) -> Self {
let mut t = Self::collect_inner(doc, &sel.objects, None, None, true);
let (mut anchors, mut segments) = (BUDGET, BUDGET);
for (id, set) in &sel.anchors {
let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue };
for (si, sp) in path.subpaths.iter().enumerate() {
let moving = |ai: usize| set.contains(&(si, ai));
for a in sp.anchors.iter().enumerate().filter(|(ai, _)| !moving(*ai)).map(|(_, a)| a.p).take(anchors) {
t.points.push((a, Kind::Anchor));
t.xs.push((a.x, a, Kind::Anchor));
t.ys.push((a.y, a, Kind::Anchor));
anchors -= 1;
}
t.push_segments(sp, moving, &mut segments);
}
}
t
}
/// The segments of `sp` that stay put while the anchors `moving` accepts move, as targets, at
/// most `budget` of them (counted down).
fn push_segments(&mut self, sp: &SubPath, moving: impl Fn(usize) -> bool, budget: &mut usize) {
let n = sp.anchors.len();
for seg in 0..sp.segment_count() {
if *budget == 0 {
return;
}
if moving(seg) || moving((seg + 1) % n) {
continue;
}
let c = sp.segment(seg);
let bounds = Rect::from_points(c.p0, c.p1).union_pt(c.p2).union_pt(c.p3);
// A straight one as a line, so the point lands on it exactly.
self.segments.push((bounds, if sp.segment_is_line(seg) { PathSeg::Line(Line::new(c.p0, c.p3)) } else { PathSeg::Cubic(c) }));
*budget -= 1;
}
}
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>, segments: bool) -> Self {
let mut t = Targets::default();
let excluded = |id: NodeId| exclude.iter().any(|e| doc.ancestry(id).is_some_and(|a| a.contains(e)));
let add_rect = |t: &mut Targets, r: Rect, kind: Kind| {
@@ -74,7 +123,7 @@ impl Targets {
add_rect(&mut t, doc.setup.bleed_rect(ab.rect), Kind::Bleed);
}
}
let mut budget = 20_000usize;
let (mut budget, mut segment_budget) = (BUDGET, BUDGET);
doc.walk(|n| {
if budget == 0 || n.is_container() || !n.visible || excluded(n.id) {
return;
@@ -91,6 +140,11 @@ impl Targets {
t.points.push((a.p, Kind::Anchor));
budget = budget.saturating_sub(1);
}
if segments {
for sp in &path.subpaths {
t.push_segments(sp, |_| false, &mut segment_budget);
}
}
}
add_rect(&mut t, b, Kind::Edge);
});
@@ -103,6 +157,7 @@ impl Targets {
self.points.retain(|(_, k)| *k == Kind::Anchor);
self.xs.clear();
self.ys.clear();
self.segments.clear();
if cx.guides {
for g in &cx.doc.guides {
let (lines, at) = if g.vertical { (&mut self.xs, Point::new(g.pos, 0.0)) } else { (&mut self.ys, Point::new(0.0, g.pos)) };
@@ -140,11 +195,15 @@ impl Targets {
t
}
/// Snap a single point. Returns the snapped point and guide overlays.
/// Snap a single point: onto the nearest anchor or centre, else onto the nearest segment, else
/// into line with targets. Returns the snapped point and guide overlays.
pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec<Overlay>) {
if let Some((q, k)) = self.points.iter().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p))) {
return (*q, vec![Overlay::Label { p: *q, text: k.label().into(), color: MAGENTA }]);
}
if let Some(q) = self.on_segment(p, tol) {
return (q, vec![Overlay::Label { p: q, text: Kind::Edge.label().into(), color: MAGENTA }]);
}
let mut out = p;
let mut ov = vec![];
if let Some((x, from, _)) =
@@ -165,6 +224,17 @@ impl Targets {
(out, ov)
}
/// The nearest point within `tol` of `p` on a target segment.
fn on_segment(&self, p: Point, tol: f64) -> Option<Point> {
self.segments
.iter()
.filter(|(b, _)| b.inflate(tol, tol).contains(p))
.map(|(_, c)| (c, c.nearest(p, 1e-6)))
.filter(|(_, n)| n.distance_sq <= tol * tol)
.min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq))
.map(|(c, n)| c.eval(n.t))
}
/// Snap a ruler guide at `v` (the x of a vertical one, the y of a horizontal one) into line
/// with the nearest edge, centre or anchor within `tol`: where it goes, and a label there.
pub fn snap_guide(&self, vertical: bool, v: f64, tol: f64) -> (f64, Vec<Overlay>) {
@@ -335,19 +405,44 @@ pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<
/// Snap `p` to pixels or the grid when they are on, else to the smart guide `targets`, else (Snap
/// to Point) to their anchors and the ruler guides within the Snap to Point distance.
fn snap_with(cx: &ToolContext, p: Point, targets: impl FnOnce() -> Targets) -> (Point, Vec<Overlay>) {
if cx.snap_to_pixel {
return (Point::new(p.x.round(), p.y.round()), vec![]);
PointSnap::new(cx, targets).snap(cx, p)
}
/// Snapping for a dragged point, its targets gathered once (when the drag begins): what
/// [`snap_draw`] does for a point at a time.
pub struct PointSnap {
/// The smart guide targets, or with Smart Guides off those of Snap to Point, and how near (in
/// screen pixels) they pull; none with pixels or the grid snapping instead, or nothing on.
targets: Option<(Targets, f64)>,
}
impl PointSnap {
pub fn new(cx: &ToolContext, targets: impl FnOnce() -> Targets) -> Self {
let targets = if cx.snap_to_pixel || cx.snap_to_grid {
None
} else if cx.smart_guides {
Some((targets(), 5.0))
} else if cx.snap_to_point {
Some((targets().for_snap_to_point(cx), cx.snap_tolerance))
} else {
None
};
Self { targets }
}
if cx.snap_to_grid {
return (vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step()), vec![]);
/// Where `p` goes, and the guides showing why.
pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
if cx.snap_to_pixel {
return (Point::new(p.x.round(), p.y.round()), vec![]);
}
if cx.snap_to_grid {
return (vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step()), vec![]);
}
match &self.targets {
Some((t, tol)) => t.snap_point(p, cx.tol(*tol)),
None => (p, vec![]),
}
}
if cx.smart_guides {
return targets().snap_point(p, cx.tol(5.0));
}
if cx.snap_to_point {
return targets().for_snap_to_point(cx).snap_point(p, cx.tol(cx.snap_tolerance));
}
(p, vec![])
}
/// Where a dragged direction handle of anchor `ai` of subpath `si` of path `id` goes for the
@@ -453,4 +548,20 @@ mod tests {
assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 102.0));
assert!(ov.is_empty());
}
/// Dragging an anchor of a path: its other anchors and the segments not touching it stay
/// targets, but neither it nor the path's bounds (which move) are.
#[test]
fn anchor_drag_targets_leave_out_what_moves() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.set([id]);
s.anchors.insert(id, [(0, 0)].into_iter().collect());
let t = Targets::for_anchor_drag(&d, &s);
let anchors: Vec<Point> = t.points.iter().filter(|(_, k)| *k == Kind::Anchor).map(|(p, _)| *p).collect();
assert_eq!(anchors, [Point::new(200.0, 100.0), Point::new(200.0, 200.0), Point::new(100.0, 200.0)]);
assert_eq!(t.segments.len(), 2);
assert!(!t.points.iter().any(|(p, k)| *k == Kind::Center && *p == Point::new(150.0, 150.0)), "the rect's centre moves");
assert_eq!(t.snap_point(Point::new(101.0, 99.0), 4.0).0, Point::new(100.0, 100.0), "in line with its neighbours, not on itself");
}
}
+52 -32
View File
@@ -12,10 +12,11 @@
use serde_json::{Value, json};
use vectorcraft_doc::hit::{hit_test, marquee, objects_at};
use vectorcraft_doc::{NodeId, OrientedBox};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
use crate::corners::{self, CornerDrag, over_widget};
use crate::guides::Targets;
use crate::rulerguide::GuideEdit;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
@@ -55,14 +56,54 @@ pub struct SelectionTool {
state: State,
measure: Option<(Point, String)>,
guides: Vec<Overlay>,
targets: Option<crate::guides::Targets>,
/// View → Snap to Point (Smart Guides off): the anchors and ruler guides the grabbed point
/// lands on while moving.
points: Option<crate::guides::Targets>,
start_bounds: Option<Rect>,
/// What a bounding-box handle being dragged snaps to.
targets: Option<Targets>,
moving: Option<MoveSnap>,
guide: GuideEdit,
}
/// Snapping for the selection moved as a whole, its targets gathered when the move begins: Smart
/// Guides line its bounds up with the other art; with them off, View → Snap to Point lands the
/// point it was grabbed by on an anchor or a ruler guide; Snap to Pixel puts its top-left on whole
/// pixels.
pub(crate) struct MoveSnap {
bounds: Option<Rect>,
targets: Option<Targets>,
points: Option<Targets>,
}
impl MoveSnap {
pub(crate) fn new(cx: &ToolContext) -> Self {
Self {
bounds: selection_bounds(cx),
targets: cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None)),
points: Targets::snap_to_point(cx, &cx.selection.objects),
}
}
/// The move by `d` of the selection grabbed at `start`, snapped, and its guides.
pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2) -> (Vec2, Vec<Overlay>) {
let mut guides = vec![];
if let Some(t) = &self.points {
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
d = q - start;
guides = ov;
}
if let (Some(t), Some(b)) = (&self.targets, self.bounds) {
let (adj, ov) = t.snap_rect(b + d, cx.tol(5.0));
d += adj;
guides = ov;
}
if cx.snap_to_pixel
&& let Some(b) = self.bounds
{
d = Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0);
guides.clear();
}
(d, guides)
}
}
pub fn matrix_json(a: Affine) -> Value {
let c = a.as_coeffs();
json!([c[0], c[1], c[2], c[3], c[4], c[5]])
@@ -155,7 +196,7 @@ impl Tool for SelectionTool {
// Only area type: the step resizes type areas; anything else scales.
let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" };
// Smart Guides align what the handle moves with the other objects.
self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None));
self.state = State::Scaling { handle, bx, areas };
return vec![Action::Begin(label.into())];
}
@@ -211,31 +252,11 @@ impl Tool for SelectionTool {
return out;
}
out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() }));
self.moving = Some(MoveSnap::new(cx));
}
let mut d = move_delta(start, p, m.shift);
if !began {
self.start_bounds = selection_bounds(cx);
self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None));
self.points = crate::guides::Targets::snap_to_point(cx, &cx.selection.objects);
}
self.guides.clear();
// Snap to Point: the point the selection was grabbed by lands on an anchor or guide.
if let Some(t) = &self.points {
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
d = q - start;
self.guides = ov;
}
if let (Some(t), Some(b)) = (&self.targets, self.start_bounds) {
let (adj, ov) = t.snap_rect(b + d, cx.tol(5.0));
d += adj;
self.guides = ov;
}
// Snap to Pixel: the moved selection's top-left lands on whole pixels.
if cx.snap_to_pixel
&& let Some(b) = self.start_bounds
{
d = vectorcraft_geom::Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0);
self.guides.clear();
if let Some(snap) = &self.moving {
(d, self.guides) = snap.snap(cx, start, d);
}
self.state = State::Moving { start, began: true, deselect: None };
self.measure = Some((p, cx.offset_label(d.x, d.y)));
@@ -279,8 +300,7 @@ impl Tool for SelectionTool {
self.state = State::Idle;
self.measure = None;
self.guides.clear();
self.targets = None;
self.points = None;
self.moving = None;
match (began, deselect) {
(true, _) => vec![Action::Commit],
(false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))],