Compare commits

...
216 Commits
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 cdb734aede Fix #219: right-click on the canvas opens a context menu
Right-clicking the canvas now opens a context menu, as the reference app
does. The object under the pointer is selected first unless it already is.
The menu then lists what applies to the selection: Undo and Redo, Cut, Copy,
Paste, Isolate Selected Group or Exit Isolation Mode, Group and Ungroup,
Join and Average, Make or Release Clipping Mask and Compound Path, Make
Guides, the Transform, Arrange and Select submenus, and Export Selection.
On empty canvas it lists Paste, the zoom commands, the ruler, grid and guide
toggles and Select All. Commands that can't run now are left out (Undo and
Redo stay, greyed out), and a press on the canvas only closes the menu. Agents list the same menu with the
`ui.contextMenu.list` control method.

Root cause: the canvas ignored the secondary button except in the toolbar's
tool flyouts; there was no context menu at all. The menu reuses the menu
bar's items, rendering and dispatch; Object > Transform and Arrange are now
shared lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:28:52 +07:00
BFlat 2f743f155f Merge pull request #227 from storytold/fix/issue-221
Fix #221: Effect > Pathfinder works on several selected objects
2026-10-07 02:27:53 +07:00
bflatasticandClaude Opus 5.5 4bc0837867 Fix #221: Effect > Pathfinder works on several selected objects
Choosing an Effect > Pathfinder item (Add, Intersect, Exclude, Subtract...)
with several loose objects selected now groups them and applies the live
effect to the group, in one undo step, so the result shows at once and stays
editable. A selection that already holds a group, or a single object, works
as before, and agents get `grouped: true` back when it happens.

Root cause: Pathfinder effects combine the contents of a group or layer.
Applied to loose objects, each one got an effect with nothing to combine, so
nothing visibly changed, while the Pathfinder panel's commands (found by the
command search) act on the selection directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:21:06 +07:00
BFlat 2ba0e1f6e5 Merge pull request #226 from storytold/fix/issue-177
Fix #177: DXF Fit and Aligned text span their two points
2026-10-07 02:20:43 +07:00
bflatasticandClaude Opus 5.5 82be706b94 Fix #177: DXF Fit and Aligned text span their two points
DXF text justified Fit now keeps its height and stretches or squeezes
horizontally to run exactly from its first point to its second; Aligned
text scales as a whole, height included, to do the same, its width factor
kept. Attributes justified the same way follow suit. Without a second point,
or with both points the same, the text keeps its size as before.

Root cause: the importer took the angle of the two points and anchored the
text at the first, but kept the text's height and width factor, ignoring the
distance between the points. The text now records that span and, when it
becomes a text object, is measured in the font it is set in, then given the
horizontal scale (Fit) or size (Aligned) that makes it end at the second
point, kept between 1% and 10,000% of its natural length. The import's
bounds estimate uses the same scale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:14:39 +07:00
BFlat 5af667182a Merge pull request #223 from storytold/fix/issue-206
Fix #206: Live Paint treats every path as an edge, open lines included
2026-10-07 02:14:17 +07:00
bflatasticandClaude Opus 5.5 700b6786c4 Fix #206: Live Paint treats every path as an edge, open lines included
The Live Paint Bucket now fills areas enclosed by any paths. Crossing
lines enclose faces it can fill, a line across a shape splits that shape's
face in two, and an area enclosed by paths becomes a face even when nothing
fills it (it starts unfilled). Edges are the paths cut wherever another path
meets them, keeping their stroke, so Shift-click paints each piece. Closed
shapes give the same faces as before.

Root cause: Live Paint built its faces from the Pathfinder regions, which
divide the filled areas of closed shapes. An open path counted only as the
area it would fill if closed, which for a straight line is nothing, so lines
divided nothing and the bucket found no face to fill. A new planar map in
vectorcraft-pathops (live_paint) sweeps every path as an edge: an open path
goes in traced there and back, with crossing counts that never cancel; faces
are walked on the resulting graph (holes included), and their fills and the
edges' sources are found geometrically. The sweep runs in an exactly sheared
frame, because it mishandles a horizontal line traced there and back.
Make, Merge, the bucket and Live Paint Selection all use it;
interior_point moves to vectorcraft-pathops with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:08:39 +07:00
Brandon Thomas 856e36c694 Merge pull request #220 from storytold/pr/craft-fonts
Fonts: use craft-fonts as an optional build input; move the Japanese fonts out
2026-10-06 14:37:41 -04:00
Brandon ThomasandClaude Opus 5.5 721d1118f3 Fonts: docs use an absolute CRAFT_FONTS_DIR; note the web size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:19:24 -04:00
Brandon ThomasandClaude Opus 5.5 3491b99321 Fonts: the Japanese-menu glyph test skips without craft-fonts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:09:46 -04:00
Brandon ThomasandClaude Opus 5.5 546a65f77a Fonts: use craft-fonts as an optional build input; move the Japanese fonts out
Shippori Mincho (assets/fonts, from #145) moves to storytold/craft-fonts. crates/text/build.rs
embeds the fonts of a craft-fonts checkout when CRAFT_FONTS_DIR is set (web: BIZ UDPGothic
Regular only) as vectorcraft_text::CRAFT_FONTS; unset, it is empty and Japanese falls back to the
installed fonts. Document text gets the Japanese faces after the bundled fonts (Mincho first); the
UI adds them at the end of every egui family (BIZ UDPGothic first), before ui_fonts' system-font
discovery. Release and FreeBSD CI builds check craft-fonts out at a pinned commit; packages carry
the fonts' OFL texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:42:21 -04:00
Brandon Thomas 77708ad8de Merge pull request #215 from storytold/pr/windows-arm64
Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
2026-10-06 10:13:23 -04:00
Brandon ThomasandClaude Opus 5.5 129c9ca61e Windows ARM64 workflow: pin third-party actions by commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:04 -04:00
Brandon ThomasandClaude Opus 5.5 ceb6defcf3 Windows on ARM64: release builds, PE header check, install test on ARM64 hardware
Release builds add an arm64 MSI and portable zip, cross-compiled on the x64
runner (signing and WiX as for x64). package.ps1 checks each binary's PE
header before packaging: Machine must match -Arch, the app must be a GUI
program and the CLI a console program. It skips the CLI smoke run when an
ARM64 build can't run on the host. A new Windows ARM64 workflow packages the
ARM64 MSI, installs it on a Windows 11 ARM64 runner, checks both installed
exes are ARM64, runs the CLI, and uninstalls. Ported from printcraft#66.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:38:29 -04:00
Brandon Thomas 7a39cb7b89 Merge pull request #212 from storytold/pr/release-0.3.1
Release: VectorCraft v0.3.1
2026-10-06 08:03:26 -04:00
Brandon ThomasandClaude Opus 5.5 15ab25426e Release: VectorCraft v0.3.1
Fixes the Windows installer: the Start Menu shortcut and Add/Remove Programs icon were blank. Release builds no longer open a console window behind the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:03:13 -04:00
Brandon Thomas 899a82d31d Merge pull request #211 from storytold/pr/windows-packaging
Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
2026-10-06 07:53:56 -04:00
Brandon ThomasandClaude Opus 5.5 7bca89dd15 Windows packaging: Start Menu/Add-Remove icon, no console window, correct description
The MSI Icon id had no .ico/.exe extension, which Windows Installer needs for
advertised shortcuts and ARPPRODUCTICON, so both showed a blank icon. Release
builds of the desktop app are now GUI-subsystem (no console window; the CLI stays
a console program), the shortcut/summary description names what VectorCraft does, and
packaging-lint rejects an Icon id without an extension. Found in printcraft#57.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:50:24 -04:00
Brandon Thomas 7f5344a60c Merge pull request #145 from midasdf/feat/japanese-interface-language
Add Japanese menus and initial vertical type tools
2026-10-06 07:43:47 -04:00
Brandon ThomasandClaude Opus 5.5 691a54e7fd Roadmap and README: Japanese interface and initial vertical type
The appended Interface localization section moves into the roadmap's structure: a
Shipped so far entry, the Type advanced row (45%, CJK composition left), the gap list,
the inventories (16 stubbed menu items; Touch Type the only missing tool) and totals
recomputed from the parity table (75.3%, 211–332 h to feature parity); README Status
follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:43:25 -04:00
Brandon ThomasandClaude Opus 5.5 1733fa5fdb i18n: keep only VectorCraft's own menu labels, and test that
47 of the 83 entries were labels from other apps ("New blank PDF", "Comments panel",
"Read mode"…) that never appear here. A test now walks the menu tree and fails on any
entry that isn't one of VectorCraft's labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:43:25 -04:00
Brandon Thomas 7715dcd285 Merge remote-tracking branch 'origin/main' into pr145-v2 2026-10-06 07:43:14 -04:00
Brandon Thomas 025315f4bf Merge pull request #210 from storytold/fix/clippy-byte-str
clippy: byte string in the envelope test (main's clippy step is red on Rust 1.99)
2026-10-06 07:09:43 -04:00
Brandon ThomasandClaude Opus 5.5 4c6ac00e72 clippy: a byte string for the envelope test's newline (byte_char_slices, red on main with Rust 1.99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 07:09:35 -04:00
midasdf 44b2b984ed i18n: add Japanese menus and initial vertical type tools 2026-10-06 16:55:47 +09:00
BFlat 2e2bb33909 Merge pull request #203 from storytold/m8-envelope-editing
M8.17: Edit envelopes on the canvas (M8 done)
2026-10-06 12:23:30 +07:00
bflatasticandClaude Opus 5.5 5bb273487e ROADMAP: M8 done (M8.1-M8.20), parity re-derived
The M8 row is done with what the fidelity pass delivered and what is left; the
Symbols/blends/envelopes/perspective parity row moves from 75% to 88%; feature
parity ~75%; Shipped so far and the README status follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:23:20 +07:00
bflatasticandClaude Opus 5.5 695c541f0d M8.17: Edit envelopes on the canvas: Edit Contents, mesh points and handles, Control bar
- Edit Contents: while an envelope's contents are edited, clicks hit the
  content where it sits undistorted and the Selection tool selects it; the
  menu item reads Edit Envelope then. No default shortcut: the reference
  app's (Shift+Cmd+V) is Paste in Place here.
- A selected envelope shows its mesh (a warp's surface grid, a mesh's own,
  a top object's outline) instead of its content's outlines.
- Mesh envelope points get bezier handles (append-only; none stored is the
  smooth Catmull-Rom mesh, which the first handle edit fills in exactly
  along the grid lines). setMeshPoint takes `handle`; object.mesh.movePoint,
  addLine and deletePoint edit mesh envelopes too. The Mesh tool and Direct
  Selection drag points and the clicked point's handles (shared MeshEdit);
  the Mesh tool adds rows and columns inside mesh envelopes. Reset with
  Mesh keeps the shape along the grid with handles; Release returns the
  exact mesh.
- Control bar: Edit Envelope / Edit Contents, warp style, orientation,
  bend and distortions or mesh rows and columns, Reset, Envelope Options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:16:59 +07:00
BFlat 254251f23a Merge pull request #201 from storytold/m8-perspective-widgets
M8.11: Perspective grid widgets: ground-level points move the grid, separate extents, cell size widget, screen-fixed Plane Switching Widget with 1-4 keys
2026-10-06 12:16:50 +07:00
bflatasticandClaude Opus 5.5 99e3aaa798 ROADMAP: M8.11 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:16:40 +07:00
bflatasticandClaude Opus 5.5 0889f13cd8 M8.11: Perspective grid widgets: ground-level points move the grid, separate extents, cell size widget, screen-fixed Plane Switching Widget with 1-4 keys
The Perspective Grid tool's left and right ground-level points move the whole
grid; the left and right extents are separate (extentRight; Alt drags both,
Shift goes by whole cells); a cell size widget sits up the line where the planes
meet; Shift keeps a vanishing point on the horizon and the ground points and
origin on one axis. The Plane Switching Widget stays put in a corner of the
document window (ViewInfo/ToolContext carry the window as a ScreenFrame), takes
its clicks with any tool while the grid shows (the Perspective Selection tool
too while it's hidden), and 1-4 pick the left, horizontal, right and no plane.
Double-clicking the Perspective Grid tool opens Perspective Grid Options
(show the widget, its corner; perspective.widget.options, kept with the
preferences).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:16:39 +07:00
BFlat 565b75b91d Merge pull request #200 from storytold/m8-perspective-type-symbols
M8.14: Type and symbols in perspective are really projected and stay editable; attachments survive copies
2026-10-06 12:10:14 +07:00
bflatasticandClaude Opus 5.5 3280586f4c ROADMAP: M8.14 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:10:02 +07:00
bflatasticandClaude Opus 5.5 8255d606ae M8.14: Type and symbols in perspective are really projected and stay editable; attachments survive copies
Type and symbol instances attached to a plane keep their art and carry a projection
(Node.perspective.projection, append-only); the canvas and every export draw their outlines
through it (reshape/bake), so they are foreshortened and stay type and symbol instances. Bounds,
hit testing and transforms follow the projection. Object › Perspective › Edit Text
(perspective.editText, or double-clicking with the Perspective Selection tool) shows the type flat
where it is drawn in isolation mode; exiting isolation projects it again. Release with
Perspective keeps their look. Copies, pastes, duplicates and Selection-tool moves keep the
attachment (the art moves as dragged). Flare, spiral and polar grid draw on the active plane, and
so do the click-to-size shape dialogs (perspective.draw `at`: sizes in plane units).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:10:00 +07:00
BFlat 5a13626faa Merge pull request #199 from storytold/m8-envelope-distort-all
M8.16: Envelopes bend images, symbols, gradients, patterns and appearance; warps keep their frame
2026-10-06 12:01:57 +07:00
bflatasticandClaude Opus 5.5 2967c3a9ba ROADMAP: M8.16 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:48 +07:00
bflatasticandClaude Opus 5.5 04fa49413c M8.16: Envelopes bend images, symbols, gradients, patterns and appearance; warps keep their frame
- Images distort as a raster mesh warp: cut by Fidelity into triangles,
  each a clipped image under the affine map of its corners (pieces overlap
  slightly, no seams; the renderer paints them without clip layers; SVG
  writes the pixels once and <use>s them).
- Symbol instances distort as their symbol's art (the renderer's cache
  follows edits to the symbol).
- Distort Appearance bakes strokes (filled outlines) and geometry effects
  before bending; Distort Linear Gradients bends linear gradients; Distort
  Pattern Fills bends the tiles. Opacity masks of the content bend too.
- Bug fix: a rotated, sheared or reflected warp envelope re-warped along
  the page axes. Envelopes now keep their own frame (append-only model
  field), so the warp turns with the art; move and scale keep it square.
- Live evaluation shares one set of hooks (vectorcraft_doc::live::Hooks,
  vectorcraft_effects::{expand_live, expanded_live_group,
  expand_live_deep}) between the canvas, Expand and every exporter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:47 +07:00
BFlat cd99714b42 Merge pull request #198 from storytold/m8-blend-fidelity
M8.20: blends interpolate strokes, gradients, groups, type and symbols faithfully
2026-10-06 12:01:39 +07:00
bflatasticandClaude Opus 5.5 e1225f4c24 ROADMAP: M8.20 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:30 +07:00
bflatasticandClaude Opus 5.5 98a3e15ec3 M8.20: blends interpolate strokes, gradients, groups, type and symbols faithfully
Closed shapes start where they twist least (unless the Blend tool
picked the anchors); dashes, miter limit, arrowhead scale and width
profiles interpolate (caps, joins and brushes switch halfway);
gradients with different stop counts resample to common stops; groups
with different member counts grow the extra members out of a point;
compound steps stay compound paths; type, symbol instances and images
interpolate their transforms, type its colours and sizes run by run.
New blends are knockout groups and the canvas draws them knocked out
as the exports do. Expand and Release keep the blend's name,
transparency, opacity mask and appearance, and Object > Expand expands
blends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:28 +07:00
BFlat 55d7df36e4 Merge pull request #197 from storytold/m8-width-tool
M8.7: Width tool edits compound-path strokes, shows every subpath's points, own cursors
2026-10-06 12:01:20 +07:00
bflatasticandClaude Opus 5.5 fe2259536d ROADMAP: M8.7 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:11 +07:00
bflatasticandClaude Opus 5.5 13db157d36 M8.7: Width tool edits compound-path strokes, shows every subpath's points, own cursors
- A compound path's stroke lives on the compound: the tool edits it through
  any member, and stroke.widthPoint.* / stroke.widthProfile.set given a member
  edit the compound's profile.
- Width points show on every subpath the stroke runs along (not just the
  first) for the hovered and the selected stroke.
- Cursors: the Width tool's own glyphs off a stroke, over a stroke (a drag
  adds a point) and over a width point or handle (a drag moves or widens it).
- Hint-bar text for the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:01:10 +07:00
BFlat bd91cf9123 Merge pull request #196 from storytold/m8-liquify-behaviour
M8.4: Liquify keeps going while held, leaves non-paths alone, strokes apply incrementally
2026-10-06 12:01:02 +07:00
bflatasticandClaude Opus 5.5 5061404ec6 ROADMAP: M8.4 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:00:53 +07:00
bflatasticandClaude Opus 5.5 e9abc62f1a M8.4: Liquify keeps going while held, leaves non-paths alone, strokes apply incrementally
Holding the brush still keeps Twirl, Pucker and Bloat applying, scaled by the
time held: a new Tool::tick (time supplied by the host: canvas frames, or
`holdMs` on ui.pointer / pointer_gesture events) repeats the last point every
0.1 s, one more dab there. Liquify never touches graphs, meshes, type, symbols,
images, or envelopes, repeats and blends with their contents, nor guides in
selection mode; those under the brush come back in `skipped` with a `warning`
that the status bar shows (UiRequest::Status). A drag applies only the dabs
each new sample adds (the stroke's state is kept between previews), giving the
same result as the whole stroke, so journal replay still reproduces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:00:52 +07:00
BFlat fc0cb255a4 Merge pull request #195 from storytold/m8-perspective-view-options
M8.10: Perspective grid view options: Lock Grid, Lock Station Point, Snap to Grid, Show Rulers
2026-10-06 11:49:33 +07:00
bflatasticandClaude Opus 5.5 aa08c5bef7 ROADMAP: M8.10 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:49:22 +07:00
bflatasticandClaude Opus 5.5 e3b079de8c M8.10: Perspective grid view options: Lock Grid, Lock Station Point, Snap to Grid, Show Rulers
View > Perspective Grid gains Show/Hide Rulers, Snap to Grid (on by default),
Lock/Unlock Grid and Lock Station Point, with flipping labels and checkmarks (and
Show/Hide Grid's label). Lock Grid stops the grid widgets from dragging; Lock
Station Point swings the other vanishing point around the station point; Snap to
Grid lands drawn corners and moved edges on gridlines within a quarter cell
(perspective.draw/move take snap?); Show Rulers draws a ruler up the true height
line in the grid's units. Gridlines draw in the Define Grid colours and opacity.
New commands perspective.grid.lock/lockStation/snap/rulers (view state).

Existing tests that measure exact perspective moves (two in tests_distort.rs,
the persp_session helper of tests_persp_select.rs) turn snapping off first (it is on by
default and would move their exact corners onto gridlines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:49:19 +07:00
BFlat 85e7acf138 Merge pull request #194 from storytold/m8-perspective-planes
M8.13: Perspective planes move along their normals with widgets, alone, with or copying their objects
2026-10-06 11:44:27 +07:00
bflatasticandClaude Opus 5.5 e774f15537 ROADMAP: M8.13 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:44:12 +07:00
bflatasticandClaude Opus 5.5 0f0dbbf9d5 M8.13: Perspective planes move along their normals with widgets, alone, with or copying their objects
The grid keeps per-plane offsets (leftOffset, rightOffset, groundOffset). Each plane has a widget
(a diamond in its colour) that the Perspective Grid and Perspective Selection tools drag: plain
moves the plane, Shift moves the objects on it too, Alt copies them (perspective.plane.move).
Double-clicking a widget, or ui.perspectivePlane, opens the plane's options dialog (location; Do
Not Move / Move All Objects / Copy All Objects). Object › Perspective › Move Plane to Match Object
(perspective.plane.matchObject). New objects attach where the plane is. Grid edits leave
attached art in place as the reference app does; perspective.grid.set {reproject: true} moves it
with the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:44:11 +07:00
BFlat 7ec936a0c1 Merge pull request #192 from storytold/m8-puppet-session
M8.6: Puppet Warp pins appear at once, warp from the rest shape and follow Undo
2026-10-06 11:36:27 +07:00
bflatasticandClaude Opus 5.5 b7c032df43 ROADMAP: M8.6 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:36:15 +07:00
bflatasticandClaude Opus 5.5 117aa0b5a3 M8.6: Puppet Warp pins appear at once, warp from the rest shape and follow Undo
- Pins show as soon as the tool has art selected: automatic ones at the
  centre and the middle of each limb's end (at least three), until pins are
  placed. They stay across tool switches while the selection is unchanged and
  a new selection starts afresh.
- The pins live in the document (Document.puppet, never saved) with the rest
  shape they started from: every warp starts from it, so warps don't stack and
  dragging a pin back restores the original; Undo/Redo take the pins back with
  the art. Adding and deleting pins are undo steps too.
- object.puppetWarp gains rest: true (the tool's pins); the new query
  object.puppetWarp.pins reads them. A click off the mesh adds nothing and
  says so (not-allowed cursor, canvas label); the command refuses such pins.
- The mesh grid covers the expanded art, so tiny art with a wide Expand no
  longer allocates millions of cells.
- Tests on groups, compound paths and open strokes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:16:10 +07:00
BFlat 47ef223a02 Merge pull request #191 from storytold/m8-blend-spine
M8.19: blend spine and keys editable on the canvas
2026-10-06 11:13:11 +07:00
bflatasticandClaude Opus 5.5 8752561132 ROADMAP: M8.19 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:13:01 +07:00
bflatasticandClaude Opus 5.5 3d35b8518f M8.19: blend spine and keys editable on the canvas
A selected blend shows its spine. Direct Selection drags spine points
and handles, the Pen tool adds and deletes points on a selected
blend's spine (object.blend.spine.moveAnchor/addAnchor/removeAnchor).
The straight spine becomes a path on the first edit, its points pinned
to the key objects (append-only BlendSpec.key_anchors): moving a key
moves its end of the spine and the other way round; spines from older
files and Replace Spine still spread the keys evenly. Direct and Group
Selection pick blend keys on the canvas, edits redraw the blend live,
and Release leaves the spine as a path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:12:58 +07:00
BFlat a5c2d6b682 Merge pull request #190 from storytold/m8-blend-options
M8.18: Blend Options dialog and the Blend tool's anchor targeting
2026-10-06 11:01:33 +07:00
bflatasticandClaude Opus 5.5 781f797b00 ROADMAP: M8.18 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:01:20 +07:00
bflatasticandClaude Opus 5.5 c5985b3f47 M8.18: Blend Options dialog and the Blend tool's anchor targeting
Blend Options is a dedicated dialog (Spacing dropdown with its value,
Align to Page / Align to Path buttons, live preview) opened on the
selected blend's own options, so OK no longer turns Smooth Color into
5 steps; with nothing selected it sets what new blends start with
(object.blend.info reads them). The Blend tool blends from clicked
anchor points (blend spec `starts`), adds keys to an existing blend
instead of nesting it, opens Blend Options on double-click, Alt-click
and its toolbar button, and has its own cursors and hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:01:18 +07:00
BFlat 5157dc87b8 Merge pull request #189 from storytold/m8-perspective-selection
M8.12: Perspective Selection tool scales, copies, moves perpendicular and nudges in perspective
2026-10-06 11:00:53 +07:00
bflatasticandClaude Opus 5.5 6210f2f15d ROADMAP: M8.12 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:00:28 +07:00
bflatasticandClaude Opus 5.5 a0847e8a25 M8.12: Perspective Selection tool scales, copies, moves perpendicular and nudges in perspective
The Perspective Selection tool shows the selection's bounding box in perspective; its handles
scale in plane space (perspective.transform; Shift proportional, Alt from the centre). Alt-drag
moves copies that stay attached, pressing 5 while dragging moves along the plane's normal
(ToolKey::Digit, delivered by the canvas and MCP press_key), and the arrow keys nudge in
perspective (perspective.nudge). Transform Again repeats the last perspective move or scale in
plane space. Attachments (plane and depth) now live on the objects (Node.perspective), so copies,
duplicates and pastes keep them; grids from older files hand theirs over. Homography moves to
vectorcraft-geom. Hint-bar text for the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:00:15 +07:00
BFlat 7df7cf568a Merge pull request #188 from storytold/m8-envelope-test-dates
M8.16: Envelope export test ignores the files' time stamps
2026-10-06 10:57:50 +07:00
bflatasticandClaude Opus 5.5 b062070642 ROADMAP: M8.16 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:57:36 +07:00
bflatasticandClaude Opus 5.5 08c70dc35d M8.16: Envelope export test ignores the files' time stamps
enveloped_type_exports_distorted_in_every_format compared two exports
byte for byte, and EPS dates its file to the second (%%CreationDate), so
it failed whenever the clock ticked between them. The comparison now drops
the CreationDate/ModDate lines and still checks repeatability and that the
bend shows in every format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:56:18 +07:00
BFlat 4a59a171e2 Merge pull request #187 from storytold/m8-perspective-presets
M8.9: Perspective grid presets: built-in views, Save Grid as Preset, presets manager
2026-10-06 10:52:02 +07:00
bflatasticandClaude Opus 5.5 8974957890 ROADMAP: M8.9 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:51:15 +07:00
bflatasticandClaude Opus 5.5 c3c4185cef M8.9: Perspective grid presets: built-in views, Save Grid as Preset, presets manager
View > Perspective Grid > One/Two/Three Point Perspective list the built-in views
([1P/2P/3P-Normal View], plus Low and High views generated in code) and the saved
presets of each type; Save Grid as Preset... and Define Grid's Preset menu and Save
Preset... save the fields. Edit > Perspective Grid Presets... (previously a
mislabelled reset to the two-point grid) manages them: New, Edit, Delete, Import,
Export (.vcperspective), stored with the preferences. New commands
perspective.presets.list/save/delete/export/import; perspective.grid.preset takes
a preset name. Preset import is fuzzed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:51:12 +07:00
BFlat 3d850a2864 Merge pull request #186 from storytold/m8-liquify-options
M8.3: Liquify Tool Options dialogs, pen pressure and Brush Affects
2026-10-06 10:50:50 +07:00
bflatasticandClaude Opus 5.5 aa5b544fca ROADMAP: M8.3 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:50:23 +07:00
bflatasticandClaude Opus 5.5 92cadd6083 M8.3: Liquify Tool Options dialogs, pen pressure and Brush Affects
Double-clicking a Liquify tool opens its Tool Options (dialog liquifyOptions):
the shared Global Brush Dimensions (Width, Height, Angle, Intensity, Use
Pressure Pen), the tool's options (Detail, Simplify with its checkbox, Twirl
Rate, Complexity, Brush Affects, Horizontal/Vertical) and Show Brush Size.
The canvas captures pen pressure from pen/touch force, ui.pointer and MCP
pointer events take `pressure`, and object.liquify takes [x, y, pressure].
Fixes: LiquifyParams::to_json now sends affectAnchors/In/Out; Alt-drag sizes
the brush from its current size (Shift keeps proportions) instead of
collapsing it. Hint-bar text for the seven tools. Registers the MCP Liquify
tests file added in M8.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:50:16 +07:00
BFlat 4950cc17cf Merge pull request #185 from storytold/m8-puppet-pins
M8.5: Puppet Warp pins rotate, multi-select and Select All; Control bar and hint
2026-10-06 10:49:38 +07:00
bflatasticandClaude Opus 5.5 c6e69fad33 ROADMAP: M8.5 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:48:12 +07:00
bflatasticandClaude Opus 5.5 67b2213957 M8.5: Puppet Warp pins rotate, multi-select and Select All; Control bar and hint
- Alt-dragging near (not on) a selected pin turns the art around it; the pin
  keeps that turn through later warps. arap::Pin gains an angle constraint and
  object.puppetWarp takes angles: [deg|null...].
- Shift-click adds pins to the selection or takes them out; dragging a pin
  moves every selected pin. Select All Pins selects them all (it used to
  deselect). Alt-click no longer deletes a pin; Delete removes the selection.
- Control bar: Expand (0 allowed), Show Mesh (on by default), Select All
  Pins. Hint-bar text for the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:28:05 +07:00
BFlat 5a93e78ad8 Merge pull request #184 from storytold/m8-envelope-text-export
M8.16: Enveloped type exports distorted, run by run, everywhere
2026-10-06 10:27:18 +07:00
bflatasticandClaude Opus 5.5 aafe381eab ROADMAP: M8.16 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:27:09 +07:00
bflatasticandClaude Opus 5.5 42be928367 M8.16: Enveloped type exports distorted, run by run, everywhere
Every exporter (SVG, PDF, EPS, EMF/WMF, DXF) evaluated live objects with no
text outliner, so type inside an envelope was written undistorted. They now
share vectorcraft_effects::expand_live_deep, which outlines type per run in
each run's paint, as the canvas now does too (it used the first run's paint
for the whole line).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:27:08 +07:00
BFlat 3bb1431422 Merge pull request #183 from storytold/m8-envelope-dialogs
M8.15: Envelope dialogs, Reset with Warp/Mesh, full Envelope Options
2026-10-06 10:26:59 +07:00
bflatasticandClaude Opus 5.5 4b5d776a73 ROADMAP: M8.15 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:26:47 +07:00
bflatasticandClaude Opus 5.5 fe6d6640fb M8.15: Envelope dialogs, Reset with Warp/Mesh, full Envelope Options
Make with Warp and Make with Mesh open dedicated dialogs (style dropdown,
orientation, bend and distortion sliders; rows and columns) with live
preview; while an envelope is selected the menu shows Reset with Warp /
Reset with Mesh instead, which switch the envelope's kind and keep its
content (Maintain Envelope Shape samples the current surface). Envelope
Options is a full dialog prefilled from the selection (Anti-Alias,
Preserve Shape, Fidelity, Distort Appearance, Distort Linear Gradients,
Distort Pattern Fills); with nothing selected it sets the defaults for new
envelopes. object.envelope.info reads the settings. Release returns the
warp or mesh shape as a gradient mesh and keeps opacity, blend mode and
mask; Expand keeps name, knockout, isolation and mask; Object > Expand
expands envelopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:11:10 +07:00
BFlat 6994f248b2 Merge pull request #182 from storytold/m8-define-grid
M8.8: Define Grid dialog prefilled from the grid, with viewing angle, units, scale, colours and opacity
2026-10-06 10:08:39 +07:00
bflatasticandClaude Opus 5.5 f29e139199 ROADMAP: M8.8 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:08:07 +07:00
bflatasticandClaude Opus 5.5 ecf245b7c9 M8.8: Define Grid dialog prefilled from the grid, with viewing angle, units, scale, colours and opacity
View > Perspective Grid > Define Grid... opens a dedicated `perspectiveGrid` dialog
(ui.perspectiveGridDialog) prefilled from the current grid instead of a generic
form with fixed defaults that forced kind 2. The grid model gains (append-only)
name, viewing angle (the station point), units, scale, plane colours and opacity;
grids with a viewing angle foreshorten each axis as a true camera. New commands:
perspective.grid.get (read-only) and perspective.grid.define (one undo step,
only changed fields apply).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:40:26 +07:00
BFlat fe7da442ca Merge pull request #180 from storytold/m8-delete-in-tools
M8.1: Delete/Backspace remove the selected width points and Puppet Warp pins
2026-10-06 09:40:10 +07:00
bflatasticandClaude Opus 5.5 37c82d082e ROADMAP: M8.1 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:39:58 +07:00
bflatasticandClaude Opus 5.5 5d21826fa8 M8.1: Delete/Backspace remove the selected width points and Puppet Warp pins
The Width and Puppet Warp tools now claim Delete and Backspace while they
have width points or pins selected, so the shortcut routing (desktop and MCP
press_key) removes those instead of clearing the artwork. With nothing of the
tool's selected the keys still clear the selected objects. Headless Backspace
clears the selection like the desktop app does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:26:55 +07:00
BFlat a05e251622 Merge pull request #179 from storytold/m8-tool-settings
M8.2: tool options last across tool switches and launches
2026-10-06 09:26:38 +07:00
bflatasticandClaude Opus 5.5 28697019b7 ROADMAP: M8.2 landed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:26:25 +07:00
bflatasticandClaude Opus 5.5 08108f693f M8.2: tool options last across tool switches and launches
Each tool keeps its options (a table in vectorcraft_tools::settings) when another
tool is chosen, across document switches, and in the saved preferences
(Prefs::tool_settings). The 7 Liquify tools share one set of Global Brush
Dimensions; the Symbolism tools share their brush. tool.setOption takes
{values} and a `tool` (another tool's options are stored for later), returns
the options, and works headless (MCP host command).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:23:44 +07:00
Brandon Thomas 2c02502f43 Merge pull request #176 from theironchef/fix/dxf-hatch-spline-edge
DXF import: a hatch spline edge without fit data keeps its loop
2026-10-05 21:51:30 -04:00
Brandon Thomas e35f75f733 Merge pull request #174 from theironchef/fix/regions-one-pass
Pathfinder: find all regions in a few contour passes, not one per mask
2026-10-05 21:51:26 -04:00
Brandon Thomas 3f0bce6a88 Merge pull request #138 from theironchef/fix/clippy-1.99
Lint: as_chunks for constant chunk sizes, as clippy 1.99 asks
2026-10-05 21:51:22 -04:00
theironchefandClaude Opus 5.5 a4d93b1462 Lint: as_chunks in the font scan too
The system font scan reads collection offsets and table records with
chunks_exact; clippy 1.99 asks for as_chunks there as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:17:22 -07:00
theironchefandClaude Opus 5.5 719e648de1 Lint: as_chunks in two tests too (import_fuzz, render encode)
With the library hits fixed, clippy 1.99 reaches two more in test code.
cargo clippy --workspace --all-targets -- -D warnings now passes on 1.99.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:14:56 -07:00
theironchefandClaude Opus 5.5 945dcbae1d Lint: as_chunks for constant chunk sizes, as clippy 1.99 asks
Rust 1.99's clippy adds chunks_exact_to_as_chunks, and cargo clippy
--workspace --all-targets -D warnings (cargo xtask ci) failed on its eight
hits in metafile, pdf and eps. as_chunks gives fixed-size arrays, so the
loops read the same bytes without indexing into slices, and encrypt's
CBC loop takes the remainder from as_chunks instead of the iterator.
as_chunks is stable since 1.88.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:14:56 -07:00
theironchefandClaude Opus 5.5 86dbc42d42 DXF import: a hatch spline edge without fit data keeps its loop
A spline edge read an optional fit count (97) after its control
points. Files before DXF 2010 have no fit data there, so the 97 that
follows is the loop's count of source objects: the edge took it, then
failed reading fit points where the 330 handles are, and the whole
hatch was dropped without a warning. That is every associative hatch
bounded by a spline from AutoCAD R2000 to R2007.

A fit count is 0 or followed by fit points (11), and a source count
by handles (330), so the edge now only takes the 97 when it is 0 or
points follow.

Fixes #175.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:14:39 -07:00
theironchefandClaude Opus 5.5 9002f86f73 Pathfinder: find all regions in a few contour passes, not one per mask
regions() made one linesweeper contour pass per distinct coverage mask,
and each pass walks the whole arrangement, so finding the regions cost
masks x segments. Two crossing blends make thousands of both: the
Ribbons example's EPS export never finished, and Flatten Transparency,
the Flattener Preview, Divide, Shape Builder and Live Paint slowed down
the same way on dense art.

The masks now go into batches whose faces share no vertex (a greedy
colouring of the masks meeting at each vertex), one contour pass per
batch. A face's contours are walked as in its own mask's pass, since
no other face of the batch touches it; each contour's mask is the one
of its batch at its first point (a vertex of the arrangement), and
contours group under the nearest enclosing contour of the same mask,
as grouped() does in that mask's pass. The regions come out identical,
in the same order.

Fixes #173.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 12:10:36 -07:00
BFlat fd99375b1b Merge pull request #166 from theironchef/fix/layers-rename-commit
Layers: renaming a layer or object commits on Enter or a click elsewhere
2026-10-06 01:04:22 +07:00
BFlat f24198ec7e Merge pull request #160 from theironchef/fix/undo-during-interaction
Edit: Undo during typing or a drag takes back only that, and Redo returns it
2026-10-06 01:04:16 +07:00
theironchefandClaude Opus 5.5 8c8ac37300 Layers: an open rename stays with its document
Node ids are per document, so a rename left open while another document
came to the front renamed that document's node with the same id. The
panel drops an open rename when the document it shows changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 10:58:54 -07:00
BFlat 5e901c9c17 Merge pull request #172 from storytold/fix/issue-81
Fix #81: Chinese, Japanese and Korean names show in the UI, not boxes
2026-10-06 00:57:08 +07:00
BFlat b71e843c96 Merge pull request #171 from storytold/fix/issue-36
Fix #36: the font menus list the installed fonts, with a search field
2026-10-06 00:56:54 +07:00
BFlat 2d740acbd1 Merge pull request #170 from storytold/fix/issue-130
Fix #130: installed fonts are found by name whatever ran before
2026-10-06 00:56:40 +07:00
BFlat 242e8a5fd5 Merge pull request #169 from storytold/fix/issue-122-halftone
Fix #122: Object > Vector Halftone
2026-10-06 00:56:27 +07:00
BFlat d0aeeb0076 Merge pull request #168 from storytold/fix/issue-122-color-adjust
Fix #122: colour adjustment live effects (Effect > Color Adjustments)
2026-10-06 00:56:14 +07:00
BFlat 39ac89fc26 Merge pull request #167 from storytold/fix/issue-122-cut-tools
Fix #122: Mirror & Cut, Line Cut and Rectangle Cut tools
2026-10-06 00:55:58 +07:00
theironchefandClaude Opus 5.5 11740c9731 Layers: renaming a layer or object commits on Enter or a click elsewhere
The name field asked for focus every frame, which took it back from
Enter, Escape or a click elsewhere, so lost_focus never fired and the
rename was never committed. It now asks only while opening, as the
Artboards panel's name field does.

Fixes #165

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 10:51:46 -07:00
bflatasticandClaude Opus 5.5 c4b87e36b2 Fix #81: Chinese, Japanese and Korean names show in the UI, not boxes
Layer and object names, document tabs and any other UI text in scripts the
UI fonts lack now draw in an installed font that covers them, as the canvas
already did.

Root cause: egui only had the bundled UI fonts (Source Sans 3, JetBrains
Mono) and its defaults, none with CJK glyphs. After each frame the UI now
looks at the text it painted; the first time a character the UI fonts lack
appears, a thread finds an installed font covering it through the font
database (the canvas's fallback search, so a font the canvas loaded is
reused, its bytes shared rather than copied) and adds it to every egui
family as the last fallback. Nothing is bundled, Latin-only UIs load no
extra font, and on the web, without system fonts, the boxes remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:49:22 +07:00
bflatasticandClaude Opus 5.5 f12428264c Fix #36: the font menus list the installed fonts, with a search field
Type > Font, the Character, Properties and Glyphs panels and Find Font list
every installed font by default, as the reference app does, instead of the
four bundled families. The family dropdowns get a search field that filters
the list as you type (Enter picks the first match) and open on the current
font. Show System Fonts, which no longer means anything, becomes Refresh Font
List (text.rescanFonts): it picks up fonts installed since the app started and
redraws type set in them without editing the document. text.fontList lets MCP
agents list the families and a family's styles.

Root cause: the font lists came from FontDb::families(), which only held the
bundled fonts until something scanned the system fonts (fixed with #130). The
Type > Font menu, rebuilt every frame, now builds its items once per font
change, and renderers lay type out again when the fonts change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:49:22 +07:00
bflatasticandClaude Opus 5.5 49e8413ebe Fix #130: installed fonts are found by name whatever ran before
Opening, placing, pasting or importing a file, the font menus and MCP now find
the installed system fonts (Arial, Times New Roman...) in a fresh session, so a
file renders the same whatever happened earlier, and PDF import keeps the real
family names (Microsoft YaHei, not "Microsoft Ya Hei").

Root cause: FontDb only consulted the system font catalog, which stayed empty
until a CJK fallback or Character panel > Show System Fonts happened to scan
the font folders. Every lookup by family name (face, families, styles,
has_family) now waits for one cataloging scan per session: the app starts it
in the background, other processes run it on first use. The scan reads only
each file's table directory and name table (about 70 ms for 780 faces here,
instead of reading every font file whole), the catalog is indexed by family,
the family list is cached, and an installed style of a loaded family (a
bundled family also installed with more weights) loads when asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:49:06 +07:00
bflatasticandClaude Opus 5.5 285d94a3e6 Fix #122: Object > Vector Halftone
The issue asked for a vector halftone that turns art or an image into
vector dots, lines or shapes by tone, with angle, frequency, shape, mono
or CMYK separations and clipping to the object. Nothing like it existed.

object.vectorHalftone renders the selection offscreen, samples its tone
once per cell of a grid turned by the screen angle and places a shape
whose area follows the tone (circle, ellipse, square, diamond or line).
Mono screens follow lightness in one colour; CMYK screens follow the ink
amounts the CMYK output separates, one screen per ink at the classic
angles, multiplied over each other. Dots are clipped to the art's
outline as real geometry (only the dots crossing it go through a
boolean). It is a command rather than a live effect: the live pipeline
can't sample rendered tone when exporting. Its dialog previews live and
OK keeps it as one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:42:26 +07:00
bflatasticandClaude Opus 5.5 9920156bb6 Fix #122: colour adjustment live effects (Effect > Color Adjustments)
The issue asked for non-destructive colour adjustments in the Effect menu
and the Appearance panel that work on vector paints, live type and
embedded images. Only the destructive Edit Colors commands existed, and
the live-effect pipeline only knew geometry and raster effects.

Effect > Color Adjustments adds six effects (adjust.*). They recolour,
in stack order, everything an object paints with: fills and strokes
(gradient stops too), type, gradient meshes, embedded images and a
group's members, each colour keeping its model. The Edit Colors walk over
an object's colours moved to vectorcraft_doc::recolor so the commands
and the live effects share it. The renderer draws adjusted art through
its object-effect path (recoloured images cached within a memory
budget), exports bake the adjusted colours (images as recoloured
copies), and Expand Appearance makes them permanent. The effect dialog
shows sliders, a channel menu and a draggable Curves graph, all previewed
live as one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:42:26 +07:00
bflatasticandClaude Opus 5.5 a844503e4c Fix #122: Mirror & Cut, Line Cut and Rectangle Cut tools
The issue asked for one-step symmetric shapes, splitting all selected art
along a line and cropping art to a rectangle as real geometry. Only the
freehand Knife and the Scissors existed, and neither works on compound
paths as one shape.

New commands path.mirrorCut, path.lineCut and path.rectCut cut the
selected paths and compound paths (inside groups too) with pathops
booleans against a half-plane or rectangle, and split open paths with
the Knife/Path Eraser helpers. Mirror & Cut reflects the kept half and
unites it with itself (open halves are joined on the axis). The draw2
piece helpers now rebuild compound paths too. Three tools (Eraser group,
original icons) preview the cut live while dragging and commit it as one
undo step; Mirror & Cut's axis and kept side are tool options shown in
the Control bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:42:26 +07:00
BFlat 1c46c4c654 Merge pull request #164 from storytold/fix/issue-31
Fix #31: the Save Changes dialog fits its content and the window at every UI scale
2026-10-06 00:41:25 +07:00
BFlat d396bc23af Merge pull request #163 from storytold/fix/issue-44
Fix #44: right-clicking a grouped tool opens its sub-tools flyout
2026-10-06 00:41:10 +07:00
BFlat 9106e344f8 Merge pull request #162 from storytold/fix/issue-84
Fix #84: the toolbar and the panel icon column scroll in short windows
2026-10-06 00:40:57 +07:00
BFlat 52d6b9a2ba Merge pull request #161 from storytold/fix/issue-82
Fix #82: the window fits the screen and comes back where it was
2026-10-06 00:40:44 +07:00
theironchefandClaude Opus 5.5 2076f82b47 Edit: Undo during typing or a drag takes back only that, and Redo returns it
edit.undo cancelled an open interaction (rolling back the typing session
or drag preview without recording it) and then undid the step before it:
one Undo removed two steps, and the typing could not be redone. Undo now
commits the interaction first, so it is the step taken back and Redo can
restore it; commit_interaction does nothing when nothing is open or
nothing changed.

Fixes #159

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D
2026-10-05 10:39:53 -07:00
bflatasticandClaude Opus 5.5 a52c030963 Fix #31: the Save Changes dialog fits its content and the window at every UI scale
The Save / Don't Save / Cancel prompt (and every dialog in the shared
dialog frame) is as tall as its text and buttons at every UI Scaling value,
including when the scale changes while it is open, and never wider than the
window: at 2x in a small window the question wraps instead of running off
the left edge.

Root cause: the tall, mostly empty prompt in the report was the shared
frame's button row taking all the window's height, fixed on main by
d3df1bce (M14.1). What remained was the width: the frame's minimum and
maximum widths are in points, and at a large UI scale a small window has
fewer points than the dialog's 466 pt, so it overflowed the window. The
frame now caps both widths to the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
bflatasticandClaude Opus 5.5 25a194cc00 Fix #44: right-clicking a grouped tool opens its sub-tools flyout
A right-click on a toolbar button with sub-tools (the small triangle) opens
the flyout and it stays open until a tool in it is chosen or the user clicks
elsewhere. A long press opens it too, and releasing that press no longer
closes it or picks the button's tool.

Root cause: the right-click did open the flyout, but the flyout closes on a
click outside itself, and the release of that same right-click (on the tool
button, outside the flyout) closed it in the same frame. The release of a
long press shorter than egui's click time did the same, and also selected
the button's tool. Clicks on the flyout's own tool button now leave it to
that button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
bflatasticandClaude Opus 5.5 831d916d3d Fix #84: the toolbar and the panel icon column scroll in short windows
In a window too short for them, the toolbar (tools, Fill/Stroke, colour
chips, drawing modes, Screen Mode, Edit Toolbar) and the dock's panel icon
column scroll with the mouse wheel, or with the scroll bar that shows while
the pointer is over them, instead of being cut off at the hint bar.

Root cause: both were drawn straight into their side panels with no scroll
area, and the toolbar's only fallback (two columns) runs out below about
760 pt. They now draw inside widgets::strip_scroll, a vertical ScrollArea
next to the menus' widgets::menu_scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
bflatasticandClaude Opus 5.5 3d890203dd Fix #82: the window fits the screen and comes back where it was
The first window opens at 1440 x 900 when the screen has room for it around a
taskbar or Dock, and maximized (with a size that fits once un-maximized)
when it doesn't, as on 1600 x 900, 1366 x 768 or 150% scaled 1080p screens.
The window's size, position and maximized state are saved with the UI
preferences (UiState::window) and restored at the next launch, moved fully
onto a monitor that is still connected and maximized if it no longer fits.

Root cause: main.rs created the window at a fixed 1440 x 900 at the system's
default position, never checked it against the monitor and never remembered
it. The new apps/vectorcraft/src/window.rs places the still-hidden window
from the monitors winit reports (CreationContext::winit_window) and tracks
its geometry every frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:31:10 +07:00
BFlat 679191b4ca Merge pull request #158 from storytold/fix/issue-121
Part of #121: sandboxed WebAssembly plug-ins (object filters and live effects)
2026-10-06 00:30:34 +07:00
bflatasticandClaude Opus 5.5 811ad1efd8 Fix #121: sandboxed WebAssembly plug-ins: object filters and live effects
VectorCraft had an Additional Plug-ins Folder preference but nothing loaded from it. Port
PhotoCraft's wasmi plug-in host (same sandbox, manifest and parameter schema) as the new
vectorcraft-plugins crate (L2), extended for vector documents:

- Object filters read the selected paths and compound paths as JSON (native PathData and
  Paint forms: geometry, fills, strokes, gradients, opacity) and return the objects to keep,
  change, add or delete; the output is validated in full and applied as one undo step
  (plugin.run). A failing plug-in leaves the document untouched.
- Live-effect plug-ins are geometry effects in the appearance stack (effect id
  plugin.<id>), re-evaluated (and cached) when the object or parameters change, editable,
  exported and baked by Expand Appearance; a missing plug-in leaves the geometry as is.
- plugin.list / info / run / install / remove / reload; the preference folder installs
  every *.wasm at startup and when it changes; File > Open of a .wasm installs it (web).
- Object > Plug-ins and Effect > Plug-ins menus with dialogs generated from the parameter
  schema (live preview), the Appearance panel's fx menu, docs/plugins.md, an original
  example plug-in (Desaturate, a Rust crate outside the workspace) and fuzzing of the
  plug-in boundary (engine/tests/plugin_fuzz.rs).

Tool plug-ins and paint-changing live effects are left for later (docs/plugins.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:19:41 +07:00
BFlat cccdf96cc1 Merge pull request #157 from storytold/fix/issue-37
Fix #37: a name typed without an extension gets the chosen format's on Export too
2026-10-06 00:18:54 +07:00
BFlat 7228275f1c Merge pull request #156 from storytold/fix/issue-40
Fix #40: W/H constrain-proportions link in the Properties panel and Control bar
2026-10-06 00:18:34 +07:00
BFlat 649be860e2 Merge pull request #155 from storytold/fix/issue-33
Fix #33: position and size fields evaluate math typed before the unit suffix
2026-10-06 00:18:15 +07:00
BFlat 4f01bc13b2 Merge pull request #154 from storytold/fix/issue-85
Fix #85: empty point type placed by a click is discarded when editing ends
2026-10-06 00:17:55 +07:00
bflatasticandClaude Opus 5.5 9c707b37fc Fix #37: a name typed without an extension gets the chosen format's on Export too
Save, Save As and Save a Copy already added the save format's extension to a
name typed in the save panel, but every other write through it (Export,
Export Selection, Save as PDF, text and preset exports, Document Info) wrote
the name as typed, so "art" became an extensionless file.

The save panel's answer now goes through one rule (io::with_extension): kept
when it ends in the suggested name's extension or names a known format, else
that extension is added. The web already downloads under the suggested name
or adds the format's extension in its Save As dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:34 +07:00
bflatasticandClaude Opus 5.5 6992c843b4 Fix #40: W/H constrain-proportions link in the Properties panel and Control bar
The Transform panel had the link, but kept it in panel-local egui memory;
the Properties panel's Transform section and the Control bar had none, so
size changes there were never proportional.

The link is now the constrainProportions preference (prefs.get/prefs.set),
one toggle shared by the three places (transform::constrain_link), and every
W/H field passes it to object.setBounds as `proportional`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:34 +07:00
bflatasticandClaude Opus 5.5 6780ffae60 Fix #33: position and size fields evaluate math typed before the unit suffix
A `*` or `/` operand had to be a bare number, so `1080/2 px` (the number
replaced by an expression, the field's suffix still there) read as nothing.
The unit after a factor or divisor now measures the whole expression, in
every unit (`Unit::parse`, shared by every unit field). Numeric fields
(num_field, opt_field, plain/mixed fields) select their whole text, unit
included, when they gain focus or are double-clicked, so typing replaces it;
unitless fields gain the same arithmetic (`parse_number`, `45*2°`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:10 +07:00
bflatasticandClaude Opus 5.5 1499834de7 Fix #85: empty point type placed by a click is discarded when editing ends
Clicking with the Type tool runs text.create at once, and ending the edit
(Escape, another tool, a click elsewhere) only stopped editing, so every stray
click left an invisible empty text object and a "Type" history step.

The Type tool now remembers the point type its click placed and, when editing
ends with it still empty, runs the new text.discardEmpty command: it removes
the text and, when nothing else changed since the step that created it, drops
those steps so neither the document nor the history keeps a trace. A command
from outside the tool that takes the edited text out of the selection
(Deselect, a Layers panel pick) or removes it now ends the edit too
(Tool::after_command). Dragged area-type frames and type in or on a path keep
their frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:08:09 +07:00
BFlat 8adc629055 Merge pull request #153 from storytold/fix/issue-39
Fix #39: rotated objects keep their angle and a rotated bounding box
2026-10-06 00:03:20 +07:00
BFlat 871474f1a9 Merge pull request #152 from storytold/fix/issue-34
Fix #34: Ctrl/Cmd+Shift+V pastes in place
2026-10-06 00:03:04 +07:00
BFlat b4c39b8cb9 Merge pull request #151 from storytold/fix/issue-86
Fix #86: modifiers sent with ui.key, ui.click and ui.drag reach every handler
2026-10-06 00:02:50 +07:00
BFlat 8939caa284 Merge pull request #150 from storytold/fix/issue-80
Fix #80: the hint bar names keys as the menus do (Alt and Ctrl off macOS)
2026-10-06 00:02:32 +07:00
BFlat dd69402f8b Merge pull request #149 from storytold/fix/issue-83
Fix #83: Ctrl and + zooms in however + is typed (numpad, Shift+=, a + key)
2026-10-06 00:02:03 +07:00
bflatasticandClaude Opus 5.5 96604acffb Fix #39: rotated objects keep their angle and a rotated bounding box
Rotating an object baked the rotation into its geometry and kept nothing
else, so the bounding box, the handles and the Transform/Properties
panels fell back to the page-aligned box (the Rotate field went back to 0).

Root cause: nodes carried no record of their own axes. Each node now
keeps `bbox_angle` (append-only, written only when non-zero), which every
transform turns with the object (mirroring transforms keep the nearer of
the two possible boxes). The geometry is still baked. The selection's box
(`Document::oriented_bounds`) stands at the angle the selected objects
share. The Selection tool hit-tests and scales its handles in that frame,
and the canvas draws it rotated. `object.setBounds` measures W/H and the
reference point along it. `object.rotate {absolute}` turns the selection
to an angle, which the panels' Rotate field now shows and sets. Object >
Transform > Reset Bounding Box (previously a no-op) clears the angle in
one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:36 +07:00
bflatasticandClaude Opus 5.5 d3d6cce917 Fix #34: Ctrl/Cmd+Shift+V pastes in place
Paste in Place is Cmd+Shift+V, but egui reports every Cmd+V chord as a
Paste event (or, for what isn't text, only the key's release), never as
the key, and the handler ran Paste for every Paste event. A paste now runs
the paste command whose V chord is held (Paste in Place, Paste on All
Artboards, Paste without Formatting; user overrides included), Paste for
the Paste key and Shift+Insert. Envelope Distort > Edit Contents also had
Cmd+Shift+V; it loses the duplicate, and a test checks that no two
commands, tools or panels share a default shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
bflatasticandClaude Opus 5.5 72be324732 Fix #86: modifiers sent with ui.key, ui.click and ui.drag reach every handler
The control channel attached its modifiers only to the synthetic key and
button events, while most handlers read egui's held modifiers
(i.modifiers), which only the keyboard set. The raw input hook now sets
egui's modifiers (a ModifiersChanged event) for the frames synthetic input
spans: a key's press, text and release, a click, and a drag from press
through its moves to release; the keyboard's modifiers, tracked from the
host's events, come back after. ui.key's text now comes with the press.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
bflatasticandClaude Opus 5.5 638edec175 Fix #80: the hint bar names keys as the menus do (Alt and Ctrl off macOS)
The hint bar's segments were hard-coded strings with macOS names (Option,
Cmd) next to one Alt. The hints now write keys as shortcut strings
("Alt+Click") and show them through the menus' pretty_shortcut, and the
fallback hint shows the effective Search Commands shortcut (or the menu
item when it has none).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
bflatasticandClaude Opus 5.5 03ea4079f9 Fix #83: Ctrl and + zooms in however + is typed (numpad, Shift+=, a + key)
The shortcut parser mapped "=" to Key::Equals and the matcher only took
that exact key, while egui reports the numpad plus, and + on layouts that
give it its own key (and often Shift+=), as Key::Plus. "+" and "=" now
parse to one chord key, a recorded + is stored as =, and the matcher lets
a = chord take Key::Plus too (also next to the native macOS menu, which
handles only the chord as written).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:49:20 +07:00
BFlat e94dfed775 Merge pull request #148 from storytold/fix/issue-136
Fix #136: SVG export and CSS write each style's numeric font-weight
2026-10-05 23:48:42 +07:00
BFlat 2f75b6cd29 Merge pull request #147 from storytold/fix/issue-139
Fix #139: SVG import paints paint-order="stroke" text strokes under the letters
2026-10-05 23:48:24 +07:00
BFlat 69dc539db5 Merge pull request #146 from storytold/fix/issue-131
Fix #131: SVG import keeps non-scaling strokes at their screen width
2026-10-05 23:47:51 +07:00
bflatasticandClaude Opus 5.5 30550f038a Fix #136: SVG export and CSS write each style's numeric font-weight
font_props wrote `bold` for any style whose name contained bold, black
or heavy and nothing for the others unless fonts were embedded, so
Semibold came back Bold and Light or Medium came back Regular. It now
writes the style's numeric weight (font_descriptor, from style_weight)
for every writer: 600 for Semibold, 300 for Light, 900 for Black, `bold`
for 700 and nothing for 400. The SVG import already reads these numbers
back to the matching face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:36:29 +07:00
bflatasticandClaude Opus 5.5 535220cf34 Fix #139: SVG import paints paint-order="stroke" text strokes under the letters
The text import reads <text> from the XML and never looked at
paint-order, so character strokes always painted over the fill. A text
whose characters all share one stroke painted first now gets that
stroke as the object's own stroke below the Characters row (as the
canvas, SVG and PDF export already draw it), with its gradient or
pattern resolved in the document; characters with differing strokes
keep them and the import warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:36:29 +07:00
bflatasticandClaude Opus 5.5 65dd757d59 Fix #131: SVG import keeps non-scaling strokes at their screen width
usvg knows vector-effect but drops it from the tree it returns, so every
stroke was scaled by its transforms. The importer now records the shapes
that ask for vector-effect="non-scaling-stroke" in its XML prepass (by id)
and gives their strokes and dashes the width they have on screen; type
does the same on its character strokes. Where the width can't be kept
(art in patterns, markers or <use> copies, stretched or skewed type) the
import warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:36:29 +07:00
BFlat 088ffc844b Merge pull request #144 from storytold/fix/issue-87-rust-version
Fix #87: rust-version 1.95 and clippy 1.95
2026-10-05 23:05:47 +07:00
bflatasticandClaude Opus 5.5 885b7acac1 Fix #87: rust-version is 1.95, and clippy 1.95 passes
egui 0.36 needs rustc 1.95 (hayro and krilla 1.92), so the declared 1.90 let
cargo fail on dependency versions instead of naming the toolchain. Clippy 1.95
also flagged four collapsible matches that newer clippy no longer does; each is
rewritten without changing behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:05:32 +07:00
BFlat 63bf2c4598 Merge pull request #143 from storytold/pr/scroll-long-menus
M14.1, M14.3, M14.7: scrolling menus, Home button, Cmd+N opens New Document
2026-10-05 22:44:21 +07:00
bflatasticandClaude Opus 5.5 d0d934bfda M14.7: Cmd/Ctrl+N opens the New Document dialog
The engine's file.new also claimed Cmd+N and engine commands win shortcut
lookups, so the shortcut made a blank document. file.newDialog now owns it,
as the docs already said; file.new stays the headless command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:43:48 +07:00
bflatasticandClaude Opus 5.5 258337cf7c M14.3: the Home button shows the Home screen over open documents
It used to open the New Document dialog. Now it runs the new app.home UI command:
the Home screen replaces the canvas while the documents stay open as tabs;
choosing a tab, or a document opening, closing or becoming active, returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:43:48 +07:00
bflatasticandClaude Opus 5.5 d4c4361b4a M14.1: long menus scroll inside the window and use all the height they have
Menu bar menus, their submenus, panel menus, the effects menu groups and library
submenus are as tall as their items down to the window's bottom edge, and scroll
only past that (widgets::menu_scroll), instead of running off screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:43:48 +07:00
Brandon Thomas 36a6a8ae49 Merge pull request #129 from storytold/pr/freebsd-ci-sync
FreeBSD CI: match the shared template
2026-10-05 10:04:50 -04:00
Brandon ThomasandClaude Opus 5.5 2b15d3234f FreeBSD CI: match the shared template (git + safe.directory, 4 vCPUs, pinned vmactions/freebsd-vm)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:45:18 -04:00
Brandon Thomas a867c1e020 Merge pull request #126 from storytold/pr/freebsd
FreeBSD: build and test the workspace in a FreeBSD VM
2026-10-05 08:50:50 -04:00
Brandon ThomasandClaude Opus 5.5 7c0a253b50 docs: point agents to the shared test corpora (#125)
Real-file test oracles live in storytold/photocraft-corpus, explained in
craftrules standards/test-corpora.md. Never commit large binary fixtures
here; fetch them pinned and sha256-verified.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:24:28 -04:00
BFlat 7a861ae064 Merge pull request #127 from storytold/pr/release-0.3.0
Release: VectorCraft v0.3.0
2026-10-05 19:23:31 +07:00
Brandon ThomasandClaude Opus 5.5 466f863d00 FreeBSD: build and test the workspace in a FreeBSD VM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:23:19 -04:00
bflatasticandClaude Opus 5.5 a1deca3d13 Release: VectorCraft v0.3.0
Bump the workspace version 0.2.0 -> 0.3.0 (cargo xtask version set 0.3.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:23:19 +07:00
BFlat cd8973a520 Merge pull request #124 from storytold/pr/m4-pdf-thumbs-linearize-13
M4.86–M4.87: PDF thumbnails, fast web view and PDF 1.3 (M4 done)
2026-10-05 18:06:10 +07:00
bflatasticandClaude Opus 5.5 79581c7ff5 M4.87: PDF 1.3 compatibility: transparency flattened with the chosen preset
compatibility "1.3" writes a %PDF-1.3 file (its XMP metadata says 1.3
too) without transparency: a copy of the document is flattened first
with flattenerPreset (stored with PDF presets; empty is High Resolution)
and the flattener options over it, see-through image pixels counting as
transparency and rasterized areas clipped to their regions so no soft
mask is needed. Page groups are left out and the writer refuses a file
that would still have transparency. PDF/X-1a and PDF/X-3 share this
path (their version is now PDF 1.3; 1.4 is still accepted and written
as 1.3), and with it the preset, the header and metadata code and the
transparency check. Passwords at PDF 1.3 encrypt with 40-bit RC4
(revision 2). The Save PDF dialog lists PDF 1.3 and picks the flattener
preset under Advanced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:05:36 +07:00
bflatasticandClaude Opus 5.5 8961824876 M4.86: PDF page thumbnails and fast web view (linearised files)
Embed Page Thumbnails gives every page a /Thumb image: the page drawn on
white, 106 px on its long side, without the layers the page leaves out
(the engine draws them; vectorcraft_pdf::page_areas says what each page
shows; PDF/X files get grey ones). Optimize for Fast Web View writes a
linearised file: the objects are put in linearised order and numbering,
encrypted when a password asks (the encryption dictionary goes with the
catalog, PDF layers' groups too), then laid out with the linearization
dictionary, both cross-reference tables and the page offset and shared
object hint tables (the hint stream encrypted like any other). Their
"not applied yet" warnings are gone. Reading the objects back uses the
lexer of encrypt.rs (syntax.rs adds what rewriting needs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:05:36 +07:00
BFlat 87df4a0648 Merge pull request #123 from storytold/pr/m4-pdf-layers-overprint
M4.82–M4.83: PDF layers and overprint
2026-10-05 17:40:04 +07:00
bflatasticandClaude Opus 5.5 a09cbe58c8 M4.83: overprinting fills and strokes overprint in PDF
Advanced > Overprint: Preserve (the default; document.exportPdf
advanced.overprint) writes each overprinting fill and stroke (an object's
own, a character's, a type object's) with a graphics state of /OP true
/op true /OPM 1 (/OPM 0 in PDF/A); Discard writes none. White ones knock out
when Document Setup's Discard White Overprint is on, as in print. Mask art
never overprints. File > Print's composite PDF honours Print > Advanced >
Overprints: Preserve the same way (separations keep multiplying plates).

Each overprinting fill or stroke is drawn as a form marked to overprint
(crates/pdf/src/forms.rs); after writing, its content starts by setting the
overprinting graphics state, and the PDF/X self-check runs on the result.
The engine's "overprint is not written to PDF yet" warning and print's
"overprints print as knockouts in composite output" warning are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:39:28 +07:00
bflatasticandClaude Opus 5.5 0e4d3f6871 M4.82: PDF export writes top-level layers as PDF layers
Create PDF layers from top-level layers (document.exportPdf createLayers, the
Save PDF dialog's General option) now writes an optional content group per
top-level layer (template layers are left out): named as the layer, off when
the layer is hidden, /PrintState /OFF when its Print option is off, locked
when it is locked, listed top first, with a print auto-state (not under a
standard). The file reopens with those layers.

The PDF writer has no optional content, so each layer is drawn as a form of
its own (no transparency group, so it draws as in place) starting with a mark;
after writing, the uses of the layers' forms are wrapped in /OC ... BDC/EMC
and the catalog gets /OCProperties (crates/pdf/src/forms.rs, through the
shared patch writer and the PDF syntax reader of encrypt.rs). PDF 1.4 and a
knockout page group write plain page content with a warning; the dialog greys
the option at 1.4. The "not written yet" warning is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:39:28 +07:00
BFlat d7e9f2a47a Merge pull request #120 from storytold/pr/m4-print-tiling
M4.80–M4.81: Print Tiling and advanced print options
2026-10-05 17:27:45 +07:00
bflatasticandClaude Opus 5.5 1f922fcbbc M4.81: Print advanced options: overprints, flattener, printer profile, bitmap
The pdf crate's PrintSettings gains advanced {printAsBitmap,
overprints: preserve|discard|simulate, flattenerPreset} and
color.profile, all defaulting so saved print_setup values still load.
Composite output discards overprints (no /OP) or simulates them as
Overprint Preview draws them; separations keep them. A printer profile
converts composite colours with the chosen intent (a CMYK one writes
device CMYK) and separations separate with a CMYK one. The engine
flattens transparency with the preset, or renders each printed region
as one image for Print as Bitmap, before PDF or PostScript layout.
The Print dialog's Advanced and Color Management sections show them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:27:00 +07:00
bflatasticandClaude Opus 5.5 1b9b37454b M4.80: Print Tiling tool and View > Show Print Tiling
View > Show Print Tiling (view.printTiling) draws the pages of the
document's print settings on the canvas, dashed and numbered, from
print.preview's own layout (print.tiling), so the canvas and the
printed job agree. The Print Tiling tool replaces the no-op tool: a
drag places the first page's imageable corner (print.tiling.set,
saved as print_setup.tileOrigin, one undo step); a double click on
the canvas or the tool button resets it. Tiles start at the origin
and extend to cover the art; the Print dialog shows the tool's
placement with a Reset button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:26:59 +07:00
BFlat 9779d21962 Merge pull request #119 from storytold/pr/m4-pdfx
M4.84: PDF/X-1a, PDF/X-3 and PDF/X-4
2026-10-05 17:19:55 +07:00
bflatasticandClaude Opus 5.5 a825aed763 M4.84: PDF/X-1a, PDF/X-3 and PDF/X-4 files written as their standards say
document.exportPdf {standard: pdfX1a|pdfX3|pdfX4} and the PDF/X presets now
write conforming files instead of being refused:

- every PDF/X file gets a /GTS_PDFX output intent (by default the CMYK
  profile in effect, embedded), TrimBox, Trapped, GTS_PDFXVersion (and
  GTS_PDFXConformance for X-1a), a title and dates; editing data,
  passwords and, in X-1a/X-3, PDF layers are refused
- PDF/X-1a converts every colour and image to a CMYK destination,
  untagged, with CMYK spot alternates; X-3 and X-4 tag colours with ICC
  profiles
- X-1a and X-3 are written as PDF 1.3 and their transparency is flattened
  first (High Resolution preset; images with see-through pixels count,
  rasters stay opaque); X-4 keeps transparency, is PDF 1.6 at most and
  names the standard in its XMP metadata
- the written file is checked against its standard (soft masks,
  transparency groups, opacity, blend modes, RGB in X-1a, fonts not
  embedded) and refused rather than mislabelled
- choosing a standard sets the latest compatibility it allows and turns
  off what it forbids (engine and Save PDF dialog)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:19:11 +07:00
Brandon Thomas 4e72f8c0ec Merge pull request #118 from storytold/docs/honest-assessment
Roadmap: honest assessment, the gap list and where we're going
2026-10-05 06:11:31 -04:00
Brandon ThomasandClaude Opus 5.5 2b48392c36 Roadmap: honest assessment, the gap list and where we're going
- ROADMAP: a new "Honest assessment (2026-10-05)" section with:
  - scores by dimension (breadth, depth, large missing subsystems, interaction
    fidelity, look and feel, file interop, bundled content, performance,
    robustness, platforms, automation) and how far to trust the self-graded scores;
  - the prioritized gap list with one-agent hour sizes;
  - the direction (next, then, alongside, 1.0).
- ROADMAP: the parity rows for files, export, print, links and UI chrome are
  re-derived after M4 (feature parity ~74% on the rubric, 68–74% honest range;
  ~230–350 h to feature parity, ~335–520 h to full parity, one agent), the M4 and
  M11 milestone rows and the inventories are updated, and "Shipped so far" lists M4
  and the robustness work.
- README Status: where we are, what's missing, where we're going.
- AGENTS.md / CLAUDE.md: sessions start from the gap list; landing a task updates
  the assessment, the parity row and the README status in the same PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:11:03 -04:00
BFlat 491b79dd9c Merge pull request #117 from storytold/pr/m4-eps-import
M4.91–M4.92: EPS and PostScript import, print to PostScript
2026-10-05 17:07:02 +07:00
bflatasticandClaude Opus 5.5 0428e56342 M4.92: Print to a PostScript file
file.print {format: "postscript"} (the default for a .ps path) writes the
print job as Level 3 or 2 (level) PostScript instead of a PDF: DSC comments
with %%Pages the page count, one page per sheet with its paper size,
printer's marks in Registration, separations naming their ink (%%PlateColor)
with its halftone screen, a fixed flatness and film negatives. Transparency
is flattened first with flattenerPreset. The answer says which format was
written. In the app, a PostScript job is always saved, never sent to the
printer (the Print dialog's own options are unchanged).

The PDF print's layout is exposed as vectorcraft_pdf::plan (the PDF writer
uses it too); vectorcraft_eps::print draws its sheets with the EPS writer's
document walk, each sheet once however many copies print.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:01:36 +07:00
bflatasticandClaude Opus 5.5 6752b22783 M4.91: EPS and PostScript files open and place
document.open and file.place read .eps files and PostScript by any name
(PostScript .ai opens as artwork, .ait as a new untitled document). An EPS
file VectorCraft wrote restores the document it carries (restored: true);
other files run through a small clean-room PostScript interpreter (paths,
colours and spot inks, gsave/grestore, concat, line state, bind def,
procedures, loops and dictionaries, clips, shadings, images through the
decoding filters, type as point type), and fall back to their TIFF preview
with a warning when the program can't be read. Operation, nesting, memory
and path budgets keep hostile files from hanging or exhausting memory.

The clipping-group nesting the metafile reader used moves to
vectorcraft_doc::clipnest, shared by both readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:51:54 +07:00
BFlat 9f8b7334f2 Merge pull request #116 from storytold/pr/m4-print-dialog
M4.78–M4.79: Print dialog, system printing and print presets
2026-10-05 16:36:16 +07:00
bflatasticandClaude Opus 5.5 7c748a5e3c M4.79: print presets in the Print dialog and Edit > Print Presets
The Print dialog's Print Preset list loads [Default] or a saved preset
(reading [Custom] once the settings differ) and Save Preset... names the
settings as a preset. Edit > Print Presets... lists [Default] (generated,
protected) and the saved presets with how they differ from it; New... and
Edit... open the preset editor (the Print dialog's sections), Delete,
Import... and Export... use .vcprintpresets files, which File > Open also
imports. Commands print.presets.list/save/delete/import/export; the presets
live in Prefs.print_presets and persist with the preferences.

The open-dialog filter count test counts the new Print presets filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:30:46 +07:00
bflatasticandClaude Opus 5.5 ffce1cf686 M4.78: File > Print opens the Print dialog and prints through the system
The Print dialog (Ctrl/Cmd+P) edits the document's print settings in the
sections General, Marks and Bleed, Output, Graphics, Color Management,
Advanced and Summary, with a preview of each printed page under the section
list (drag the art to move its placement). It drives print.setup,
print.preview and file.print; the preview pages now carry their transform,
printed area and trim box. Print sends the job's PDF to the chosen printer
through the new PrintService (lp on macOS/Linux, the shell print verb on
Windows, a hidden frame and the browser's print dialog on the web); without
one, or with PDF File, it saves the PDF. Done only keeps the settings.
UI commands file.print, print.printers and print.printerSetup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:30:35 +07:00
BFlat f1f7b1f945 Merge pull request #115 from storytold/pr/m4-pdf-color-text
M4.73–M4.74: PDF colour conversion, profiles and real text
2026-10-05 16:29:54 +07:00
bflatasticandClaude Opus 5.5 a5367b0846 M4.74: real text with embedded subset fonts in PDF
With Convert Text to Outlines off (advanced.outlineText: false), type is
written as selectable, searchable text: each glyph in its font, embedded
as a subset with a ToUnicode map, where its outline would be (rotated,
scaled, skewed and on-path type too; gradients keep their place). Fonts
whose licence forbids embedding, or allows only whole fonts, fall back to
outlines with a warning; a subset threshold below 100% is accepted with a
warning. Laid-out glyphs now carry their glyph id and transform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:25:21 +07:00
bflatasticandClaude Opus 5.5 5b69862a08 M4.73: PDF colour conversion, ICC profiles and output intent
Save PDF > Output now applies: colours convert to a destination profile
(destination, or preserveNumbers keeping the numbers of colours already in
its model; grey stays grey; images and freeform gradient images convert
too; printer's marks don't). Profile inclusion writes ICC-based colour
spaces (CMYK with the destination's or the document's profile, RGB as sRGB,
grey), and transparency in CMYK output blends in the CMYK profile's space.
The output intent profile is embedded as the catalog's /GTS_PDFX output
intent with its condition, identifier and registry, and Trapped goes in
the document information (PDF/A keeps its own). A shared patch module
edits written PDFs (Lab spot alternates use it too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:25:20 +07:00
BFlat 51f6817a0b Merge pull request #114 from storytold/pr/m4-psd-export
M4.70: PSD export
2026-10-05 16:22:36 +07:00
BFlat ced1527903 Merge pull request #113 from storytold/pr/m4-css-properties
M4.96: CSS Properties
2026-10-05 16:13:53 +07:00
bflatasticandClaude Opus 5.5 c06c13f577 M4.70: PSD export, flat or with layers, groups for sublayers and text layers named after their text
document.export {format: "psd"} writes layered bitmaps (8 bits per channel),
written from the format's public specification: RGB, CMYK (ink amounts) or
grayscale, the resolution and an embedded profile, PackBits channels, and the
merged image (the flat render). layers (default true) draws each top-level
layer alone as a pixel layer, cropped to what it paints, with its name,
opacity and blend mode; a background colour becomes a Background layer.
maxEditability turns layers and sublayers into groups and every object into a
layer named as the Layers panel names it (text by its text); clipping, masked,
knockout or appearance-carrying layers stay one layer. hiddenLayers writes
hidden layers hidden instead of leaving them out; layers: false writes a flat
image on white. At most 30000 pixels a side.

Export As offers PSD with the psdOptions dialog (the PNG Options rows, then
Color Model, Flat Image / Write Layers with Maximum Editability and Include
Hidden Layers, Embed ICC Profile). Tests read the files back with the psd
crate (MIT OR Apache-2.0, a dev-dependency).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:12:03 +07:00
bflatasticandClaude Opus 5.5 ed4eceb837 M4.96: Window › CSS Properties shows, copies and exports the CSS of the selection
The svg crate's new CSS writer (css_rules) gives the CSS web pages style objects
with: rectangles and ellipses as background-color / linear-/radial-gradient(),
border and border-radius; type as font, color, spacing and alignment; shadows,
glows and blur as box-shadow, text-shadow and filter. It shares SVG export's
declarations (transparency, fonts, type spacing, colours, gradient stops), which
moved into svg/src/css.rs. Options: units, absolute position, width and height,
unnamed objects, rasterize unsupported art (as a PNG background-image).

Commands css.selection, css.generate and css.export; UI commands css.copy and
css.exportFile; the CSS Properties panel with a highlighted code view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:03:11 +07:00
BFlat 0f161926cf Merge pull request #112 from storytold/pr/m4-asset-export
M4.65–M4.66: Asset Export and Export Selection
2026-10-05 15:32:16 +07:00
BFlat 39a7af7eba Merge pull request #111 from storytold/pr/m4-save-for-web
M4.90: Save for Web (Legacy)
2026-10-05 15:32:03 +07:00
BFlat df4b84048e Merge pull request #110 from storytold/pr/m4-tiff-bmp-tga
M4.67–M4.69: TIFF, BMP and Targa export
2026-10-05 15:31:49 +07:00
BFlat db7164a228 Merge pull request #109 from storytold/pr/m4-emf-wmf
M4.94–M4.95: EMF and WMF
2026-10-05 15:31:36 +07:00
BFlat 6017846ef6 Merge pull request #108 from storytold/pr/m4-dxf-import
M4.93: DXF import
2026-10-05 15:31:22 +07:00
BFlat c816f16bad Merge pull request #107 from storytold/pr/m4-data-recovery
M4.37–M4.38: Data Recovery and native save options
2026-10-05 15:31:08 +07:00
BFlat 88b9eee4ad Merge pull request #106 from storytold/pr/m4-pdf-security
M4.85: PDF security
2026-10-05 15:30:53 +07:00
BFlat 2bab433c92 Merge pull request #105 from storytold/pr/m4-print-engine
M4.75: print engine
2026-10-05 15:30:39 +07:00
BFlat 1e09016b80 Merge pull request #104 from storytold/pr/m4-ft3
M14.4: flaky paste fuzz test and batch journaling
2026-10-05 15:30:26 +07:00
bflatasticandClaude Opus 5.5 46dc67ca50 M4.66: File > Export Selection collects the selection and opens the Assets tab
Export Selection… (file.exportSelection, a UI command) now collects the
selected objects as assets, one per object (multiple: false makes one),
reusing assets of art already collected, and opens Export for Screens on
its Assets tab with just those checked. document.exportSelection stays the
one-shot export for agents and scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:56 +07:00
bflatasticandClaude Opus 5.5 6757c7f9f3 M4.65: Asset Export panel and the Assets tab of Export for Screens
Window > Asset Export lists the document's assets as thumbnails: art
dragged in off the canvas (or added with +) becomes one asset per object,
or one of them all with Alt; Object > Collect for Export does the same.
Assets name their objects, so they follow edits (moving the art moves the
crop) and deleting the art removes it from its asset. Click selects,
double-click renames, the bin removes. The panel's export settings are
Export for Screens' own format rows on the document's shared settings, and
Export writes each asset alone, cropped to its art, into a picked folder
(the web downloads a zip). Export for Screens' Assets tab lists the assets
with checkboxes and exports the checked ones.

Commands: assets.add, assets.remove, assets.rename, assets.list,
assets.export, assets.settings.set; document.exportForScreens takes
assets. Model: Document.assets (ExportAsset {id, name, nodes}).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:54 +07:00
bflatasticandClaude Opus 5.5 c6450f6f70 M4.90: Save for Web (Legacy) previews and saves optimised GIF, JPEG, PNG-8 and PNG-24 images, slices and HTML
File > Export > Save for Web (Legacy) (Ctrl/Cmd+Alt+Shift+S) opens a dialog with Original,
Optimized, 2-Up and 4-Up views showing each file's size and download time, and settings for
colour reduction, colours, dither, transparency and matte, interlacing, web snap, lossy GIF,
JPEG quality, the colour table (lock, map to transparent, web shift, sort), image size and
anti-aliasing, Clip to Artboard, sRGB, metadata, slices (all or selected), HTML output and
presets. Preview in Browser opens the HTML page.

Engine: document.exportForWeb, document.exportForWeb.preview (its byte count is the size of
the file Save writes), webExport.settings and webExport.presets.list/save/delete. Saved presets
and the remembered settings are stored in Prefs. Render: quantize_locked, and encode::web for
web snap, colour table edits, lossy GIF and GIF/JPEG comments. PNG files get an sRGB chunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:51 +07:00
bflatasticandClaude Opus 5.5 b578c32f4d M4.69: Targa export at 16, 24 or 32 bits per pixel
document.export {format: "tga"} writes uncompressed true-colour Targa files
through the raster export path (resolution, background, anti-aliasing,
artboard ranges): depth 16 (5-5-5 with a one-bit alpha), 24 (flattened on
white) or 32 (8-bit alpha), rows bottom-up, with the version 2 footer.
Export As offers it with the tgaOptions dialog (Depth).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:51 +07:00
bflatasticandClaude Opus 5.5 f2fc8782c7 M4.68: BMP export at 1 to 32 bits, Windows or OS/2, with RLE and flipped rows
document.export {format: "bmp"} writes BMP through the raster export path:
depth 1 (black and white), 4 or 8 (a palette by reduction and dither,
optionally RLE4/RLE8), 16 (5-5-5), 24 or 32 (with alpha, a version 4
header); colorModel rgb or gray; fileFormat windows or os2 (a core header);
flipRows writes top-down rows (a negative height). Impossible combinations
are refused as bad params. Export As offers it with the bmpOptions dialog,
which keeps its choices writable together; BMP moves from read-only to read
and write (tests that used .bmp as the unwritable format now use .dwg).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:35 +07:00
bflatasticandClaude Opus 5.5 d01b2fbd8a M4.67: TIFF export in RGB, CMYK or grayscale, with LZW, either byte order and the profile
document.export {format: "tiff"} writes baseline TIFF through the raster
export path (resolution, background, anti-aliasing, artboard ranges):
colorModel rgb (transparency as unassociated alpha), cmyk (ink amounts,
CMYK colours keep their inks) or gray, lzw (weezl), byteOrder little (II)
or big (MM), embedIcc, and the resolution tags. Export As offers it with
the tiffOptions dialog; TIFF moves from read-only to read and write.

The TIFF writer is shared: EPS previews now use it (and its PackBits)
instead of their own copy, so eps depends on render (layers allow it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:29 +07:00
bflatasticandClaude Opus 5.5 961ca75a28 M4.95: EMF and WMF open, place and paste as vector objects
crates/metafile import: EMF and WMF (placeable or not) records play on a
small device-context model (objects numbered as GDI does, saved DCs, world
transforms and mapping modes, path brackets, the current position) into one
artboard (the picture's frame) and one layer: paths with fills and strokes
(pens with caps, joins, dashes), clipping groups for clips, images from DIBs
(StretchDIBits, BitBlt, StretchBlt, AlphaBlend, StretchDIB, DIBBitBlt) and
point type in the font the file names. Lines drawn one after another join
into one path; a clip around the whole picture is dropped; objects far
outside it are left out; unknown records are skipped with one warning.

document.open and file.place read .emf and .wmf through the shared loader
(Open and Place filters list them). clipboard.importEmf loads a picture into
the clipboard, and Paste takes image/emf from a system clipboard service
ahead of text and bitmaps. The import fuzzer covers mutated and random-record
metafiles opened, placed and pasted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:27 +07:00
bflatasticandClaude Opus 5.5 ab1c6b0516 M4.94: EMF and WMF export from Export As and document.export
New crates/metafile (L3, pure Rust): hand-written EMF and WMF writers over
one shared document walk. EMF keeps Bezier paths, solid brushes, geometric
pens (width, caps, joins, miter limit, user-style dashes), clipping masks as
path clips, images (StretchDIBits, or AlphaBlend with transparency) placed by
a world transform, type as outlines; gradients become images clipped to their
shape and pattern fills their tiles clipped to it. The header's frame is the
artboard in 0.01 mm on a 100 px/mm reference device. WMF flattens curves into
polygons in 16-bit units behind a placeable header (with its checksum),
numbers objects as players do, and reports what it leaves out (clipping,
transparency, gradients, rotated images).

document.export {format: emf|wmf} (and serialize, export) writes one artboard,
or one file per artboard with useArtboards (false: the art's bounds); raster
effects are rendered to images first, as for PDF. Export As lists EMF and WMF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:24 +07:00
bflatasticandClaude Opus 5.5 2e6585d7ea M4.93: DXF drawings open and place with layout, scale or fit, centring and merged layers
ASCII DXF of any version opens (document.open) and places (file.place) through
a new reader in vectorcraft-cad: lines, LW/2D/3D polylines with bulge arcs,
circles, arcs, ellipses, NURBS splines (exact Beziers up to cubic, fitted
otherwise), solids, solid and pattern hatches, TEXT/MTEXT/ATTRIB as point
type, named blocks as symbols (one per look for by-block art), anonymous
blocks and dimensions as groups, MINSERT arrays, OCS extrusion; layers keep
off/frozen/locked/plot state; ACI and true colour, lineweights, linetypes and
transparency resolve through layers and blocks. The `dxf` option object picks
the layout, ratio or fit, lineweight scaling, centring and merged layers;
document.dxfInfo lists layouts, layers and units. Binary DXF and DWG answer a
clear error. The DXF Import Options dialog (dxfImport) asks on open and place
with a layout preview. Fuzzed in import_fuzz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:21 +07:00
bflatasticandClaude Opus 5.5 bf4614669e M4.38: native and .ai save options: each artboard to its own file, linked files, ICC profiles, PDF content
- document.save / Save As / Save a Copy to a native or .ai file take
  separateArtboards + range (also write <name>-<artboard>.<ext> per artboard
  beside the master file, holding that artboard and the art touching it; the
  result lists every file), includeLinked (linked images keep their files' own
  pixels), embedProfiles (ICC profiles loaded from files that the document is
  tagged with; opening installs them where missing) and pdfCompatible (native:
  also carry a PDF of every artboard; .ai: false writes blank pages around the
  native document). .ai files also take compress (compression.compressText).
- File > Save As to a native or .ai file shows the options dialog (VectorCraft
  Options) after the save panel: Save each artboard to a separate file (All or a
  range), Include Linked Files, Embed ICC Profiles, Create PDF-Compatible File,
  Use Compression, Preview; the web's Save As dialog shows them too. Save, Save a
  Copy and Save as Template reuse the options as last saved.
- Format: optional "profiles" and "pdf" members (load_file, pdf_content),
  SaveOptions.include_linked/profiles/pdf; files without them are unchanged.
- Changed test: tests_aisave::save_as_ai_writes_a_pdf_that_reopens_editable now
  confirms the options dialog Save As .ai shows before writing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:19 +07:00
bflatasticandClaude Opus 5.5 d8d1d472cf M4.85: PDF security: encryption at every compatibility level, permissions, either password opens
- pdf/src/encrypt.rs encrypts a written PDF with the standard security handler: 128-bit RC4
  (PDF 1.4, revision 3; PDF 1.5, revision 4), 128-bit AES (1.6) or 256-bit AES (1.7/2.0,
  revision 6), every string and stream, plaintext XMP metadata when asked, the permission bits
  (printing none/low/high, five change levels, copy, screen readers). Crypto from RustCrypto
  (aes, md-5, sha2: MIT OR Apache-2.0, pure Rust, wasm).
- PDF/A and PDF/X refuse passwords; equal passwords and passwords the algorithm can't take are
  refused.
- Opening: the permissions password opens RC4/AES-128 files too; editing data comes back from
  an encrypted PDF opened with its password.
- Save PDF › Security: the passwords, permissions and plaintext metadata, the encryption level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:16 +07:00
bflatasticandClaude Opus 5.5 975067b44a M4.37: Data Recovery keeps copies of modified documents and offers them after a crash
- Every Preferences > File Handling > Data Recovery interval, modified documents
  changed since their last copy are written to the recovery store in the
  background (the same snapshot and encoder as Background Save; the status bar
  shows it). Complex documents (over 20,000 objects) are skipped while Turn off
  Data Recovery for complex documents is on.
- A copy goes when its document is saved, reverted, closed or undone back to its
  saved state, and when the app quits with nothing unsaved.
- Each running app keeps its copies in an area of its own: a sub-folder whose
  .lock file it keeps locked for its lifetime (File::try_lock), or on the web a
  heartbeat refreshed every minute. Only areas nobody holds (lock free, or no
  heartbeat for three intervals, at least three minutes) are offered, and an
  area is held while it is restored or discarded, so a second running instance
  never offers or deletes a live instance's copies. Empty leftover areas are
  tidied away.
- The next launch after a crash asks with the Recover Documents dialog (Restore /
  Discard / Cancel): restored documents open as "<name> [Recovered]", unsaved, and
  Save asks where to save them, suggesting their original file; the copy moves
  into the new app's area.
- Store: the recoveryFolder preference, else Data Recovery beside the desktop
  app's preferences; browser storage on the web (Services::recovery_store);
  tests use an in-memory store (with in-memory locks, or heartbeats).
- Engine: file.recovery.save, file.recovery.list, file.recovery.restore {file?},
  file.recovery.discard {file?}; DocState.recovered and DocState.recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:16 +07:00
bflatasticandClaude Opus 5.5 b379f02527 M4.75: File → Print writes the job as a print-ready PDF: paper, scale, tiles, marks and bleed, separations
file.print {settings?, path?} lays the document out on paper and writes one PDF page per sheet:
copies (collated, reversed), artboards (all, a range, ignored), blank artboards skipped, paper
size and orientation (or turned per artboard), transverse, Print Layers (template layers never
print), placement on the imageable area, no scale / fit / custom / tiles of the paper or of the
imageable area with overlap and a tile range; printer's marks and bleed as PDF export draws
them; composite or separations (one grey plate per ink that prints, overprints honoured, spots
as process), emulsion down, negative; rendering intent and preserve numbers. print.preview lists
the pages, tiles, inks and warnings; print.setup saves the settings with the document
(Document.print_setup, one undo step).

PDF export and print share one page writer; the ink separation helpers move from the renderer to
vectorcraft_doc::inks and the range parser to vectorcraft_doc::range so print can use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:13:10 +07:00
bflatasticandClaude Opus 5.5 3d87b71ed4 M14.4: a batch that opens a new document is journaled
command.batch kept its steps in one interaction of the document active
when it began and journaled itself by committing that interaction. A
step that opened, closed or reverted a document broke both: the switch
cancelled the interaction (throwing away the steps so far), and the
commit then ran in the new document, which had none, so the batch was
never journaled and a replay was missing the new document. An error
after the switch rolled back only the active document.

- Each step runs inside an interaction of the document active when it
  starts; a switch leaves it open while a batch runs, so every document
  the batch edits gets one undo step when it succeeds.
- The batch journals itself, whichever document it ends in.
- On error everything rolls back: documents the batch opened close, the
  ones it closed or reverted come back (Session::batch_stash), each
  one's interaction is undone, and the active document and the
  Untitled-N count go back too.
- DocState::keep_interaction / undo_interaction share the undo-step and
  rollback code with commit_interaction / cancel_interaction.
- Tests: a batch that opens a new document is journaled, replays to the
  same documents and keeps one undo step in each; a failed batch closes
  the document it opened and restores the one it closed; a failed batch
  brings back a document it reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:22:36 +07:00
bflatasticandClaude Opus 5.5 766b612070 M14.4: a batch journals the dates and preferences its steps used
Session::note_journal only kept values noted by the top-level command, so
a command run as a step of command.batch lost them: a save in a batch
stamped File Info's modified date from the clock without recording it,
and object.scale's strokes/corners came from the preferences again on
replay. Replaying such a journal a second later (or with other
preferences) gave a different document.

- Session::execute_step runs a batch step with its own journal notes
  (note_depth) and returns its params with what it noted; command.batch
  writes those params into its step of the batch's journal entry (steps
  that note nothing stay as given).
- journal_for_action strips the replay-only dates from a batch's steps
  too, so an action recorded with one still dates the document when it
  plays.
- Tests: a save in a batch records its date and a replay with the
  recorded date moved lands on it, while an action leaves it out; a scale
  in a batch records Scale Strokes & Effects and replays alike with the
  preference off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:05:09 +07:00
bflatasticandClaude Opus 5.5 0312f1ea55 M14.4: the pasted-PDF fuzz test accepts a paste that refuses out-of-range art
pasted_pdf_never_panics failed at random (about one run in four at 256
cases): a mutated digit turns a curve coordinate like 245.18535 into
245018535, so the pasted art spans ~1e8 pt, beyond MAX_COORD, and
edit.paste rightly refuses it with "result would exceed the canvas",
leaving the document unchanged. The helper unwrapped that result. The
test is about never panicking, so paste_from_elsewhere now ignores an
Err from edit.paste, with a comment saying why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:05:09 +07:00
BFlat e2e36f221b Merge pull request #103 from storytold/pr/m4-eps-export
M4.76: EPS export
2026-10-05 14:02:45 +07:00
bflatasticandClaude Opus 5.5 4c4850e745 M4.76: EPS export (PostScript Level 2 and 3) with EPS Options, TIFF previews and the document embedded
New crates/eps (L3): a hand-written EPS writer. A DSC header with
%%BoundingBox and %%HiResBoundingBox (y up from the origin), %%LanguageLevel
and spot colours; paths filled and stroked with caps, joins, miter limits
and dashes (strokes a plain line can't draw as filled outlines, brushes as
their art), clipping groups, linear and radial gradients as smooth
shadings at Level 3 and stepped fills at Level 2 or with compatible
gradient printing, patterns as tiles, freeform gradients and placed images
as images (Flate with masked transparent pixels at Level 3, run-length at
Level 2), type as glyph outlines, spot colours as Separation spaces and
overprints when preserved; RGB written as CMYK on request. A TIFF preview
(1-bit or colour, transparent or opaque) behind the binary header, a PNG
thumbnail and the native document in comments (eps::native and
eps::thumbnail read them back for a later import).

document.export {format: eps} and document.exportEps: transparency is
flattened first with a flattener preset (built-in or saved) and options;
the visible art's bounds, or with useArtboards one file per artboard named
{stem}_{artboard}.eps. Export As lists EPS, then EPS Options
(ui.epsOptionsDialog for agents, remembered between exports).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:50:50 +07:00
444 changed files with 70451 additions and 3421 deletions

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
# The optional craft-fonts build input (Japanese fonts), so the tests that need it run here
# rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md).
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
# The checkout above is synced into the VM with the workspace.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
+4
View File
@@ -47,6 +47,10 @@ jobs:
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
echo "::error::Icon Id must end in .ico or .exe"; exit 1
fi
desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
+41
View File
@@ -70,6 +70,13 @@ jobs:
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
@@ -90,6 +97,8 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
- uses: actions/upload-artifact@v4
with:
@@ -112,11 +121,21 @@ jobs:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
# 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -141,6 +160,8 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
@@ -167,6 +188,13 @@ jobs:
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
# 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
@@ -184,6 +212,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
@@ -212,6 +243,13 @@ jobs:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
# 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
@@ -227,6 +265,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$VECTORCRAFT_VERSION"
- name: Build and zip
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
with:
+95
View File
@@ -0,0 +1,95 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: vectorcraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: vectorcraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'VectorCraft'
foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'vectorcraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok vectorcraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' }
Write-Output 'ok uninstalled'
+2
View File
@@ -5,3 +5,5 @@ plan/
/corpus
/log
.DS_Store
# A craft-fonts checkout for CRAFT_FONTS_DIR (CI and release workflows check it out here)
/craft-fonts
+9 -3
View File
@@ -5,8 +5,9 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
@@ -21,6 +22,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
@@ -36,6 +38,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
@@ -48,4 +51,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and time-to-parity estimates. Update it whenever a milestone task lands.
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
- Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low.
- Keep the README's Status section in step with the ROADMAP headline.
+7
View File
@@ -6,6 +6,10 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo
Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`).
**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay.
**Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=<craft-fonts checkout>` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-<family>.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them.
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
@@ -143,6 +147,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-join-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-join.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-knife.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-line-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-line.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-list-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-live-bucket.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -150,6 +155,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-mask-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-measure.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-mesh.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-mirror-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -182,6 +188,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-pucker.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-puppet.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rearrange.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rect-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rect-grid.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-reference-point.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-remove-brush.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+9 -3
View File
@@ -5,8 +5,9 @@ VectorCraft is a clean-room, open-source, Rust-native vector illustration app ta
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
@@ -21,6 +22,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
@@ -36,6 +38,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
@@ -48,4 +51,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and time-to-parity estimates. Update it whenever a milestone task lands.
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
- Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low.
- Keep the README's Status section in step with the ROADMAP headline.
Generated
+332 -26
View File
@@ -121,6 +121,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -458,6 +469,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -611,6 +631,16 @@ dependencies = [
"libc",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -714,6 +744,15 @@ dependencies = [
"libm",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -763,6 +802,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -775,6 +824,16 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -1172,6 +1231,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1309,6 +1374,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -1370,7 +1445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1386,7 +1461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1534,6 +1609,15 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1542,7 +1626,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1551,7 +1635,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1802,6 +1886,15 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "itertools"
version = "0.15.0"
@@ -1981,6 +2074,12 @@ dependencies = [
"smallvec",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.189"
@@ -2073,6 +2172,16 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -2908,6 +3017,15 @@ dependencies = [
"unarray",
]
[[package]]
name = "psd"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a25f9b8cfffd65d911baf31a033239f7a0facb755faa2481575b70db5dc9195"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "pxfm"
version = "0.1.30"
@@ -3370,6 +3488,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -3559,6 +3688,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spirv"
version = "0.4.0+sdk-1.4.341.0"
@@ -3589,6 +3724,16 @@ dependencies = [
"float-cmp",
]
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.5",
"serde",
]
[[package]]
name = "subsetter"
version = "0.2.6"
@@ -3902,6 +4047,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -4060,7 +4211,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"arboard",
"clipboard-win",
@@ -4074,12 +4225,13 @@ dependencies = [
"vectorcraft-engine",
"vectorcraft-ui-egui",
"webbrowser",
"winit",
"winresource",
]
[[package]]
name = "vectorcraft-brush"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4091,7 +4243,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4107,7 +4259,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"serde_json",
"vectorcraft-color",
@@ -4123,7 +4275,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4135,7 +4287,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4149,7 +4301,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4157,12 +4309,13 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-plugins",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-engine"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@@ -4176,21 +4329,42 @@ dependencies = [
"vectorcraft-cad",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-eps",
"vectorcraft-format",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pathops",
"vectorcraft-pdf",
"vectorcraft-plugins",
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"vectorcraft-tools",
"vectorcraft-trace",
"wat",
]
[[package]]
name = "vectorcraft-eps"
version = "0.3.1"
dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-render",
"vectorcraft-testkit",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-format"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"flate2",
"proptest",
@@ -4204,7 +4378,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4216,7 +4390,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"image",
"log",
@@ -4233,9 +4407,23 @@ dependencies = [
"vectorcraft-tools",
]
[[package]]
name = "vectorcraft-metafile"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-pathops"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4247,16 +4435,20 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"aes",
"flate2",
"hayro-interpret",
"hayro-syntax",
"image",
"krilla",
"kurbo 0.13.1",
"log",
"md-5",
"serde",
"serde_json",
"sha2",
"smallvec",
"thiserror 2.0.21",
"vectorcraft-brush",
@@ -4268,9 +4460,23 @@ dependencies = [
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-plugins"
version = "0.3.1"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-geom",
"wasmi",
"wat",
]
[[package]]
name = "vectorcraft-render"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"flate2",
"gif",
@@ -4278,7 +4484,9 @@ dependencies = [
"jpeg-encoder",
"log",
"proptest",
"psd",
"serde_json",
"tiff",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
@@ -4288,11 +4496,12 @@ dependencies = [
"vectorcraft-text",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
"weezl",
]
[[package]]
name = "vectorcraft-svg"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"flate2",
"image",
@@ -4316,7 +4525,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4333,7 +4542,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
@@ -4346,7 +4555,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4361,7 +4570,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4374,7 +4583,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"egui",
"egui_extras",
@@ -4387,19 +4596,22 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-engine",
"vectorcraft-eps",
"vectorcraft-format",
"vectorcraft-geom",
"vectorcraft-pdf",
"vectorcraft-plugins",
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"vectorcraft-tools",
"wat",
]
[[package]]
name = "vectorcraft-web"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"eframe",
"egui",
@@ -4407,6 +4619,7 @@ dependencies = [
"log",
"rfd",
"vectorcraft-engine",
"vectorcraft-format",
"vectorcraft-ui-egui",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -4563,6 +4776,99 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
dependencies = [
"leb128fmt",
"wasmparser 0.261.0",
]
[[package]]
name = "wasmi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
dependencies = [
"spin",
"wasmi_collections",
"wasmi_core",
"wasmi_ir",
"wasmparser 0.228.0",
]
[[package]]
name = "wasmi_collections"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
dependencies = [
"string-interner",
]
[[package]]
name = "wasmi_core"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
dependencies = [
"libm",
]
[[package]]
name = "wasmi_ir"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
dependencies = [
"wasmi_core",
]
[[package]]
name = "wasmparser"
version = "0.228.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
dependencies = [
"bitflags 2.13.2",
"indexmap",
]
[[package]]
name = "wasmparser"
version = "0.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
dependencies = [
"bitflags 2.13.2",
"indexmap",
"semver",
]
[[package]]
name = "wast"
version = "261.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
"unicode-width",
"wasm-encoder",
]
[[package]]
name = "wat"
version = "1.261.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
dependencies = [
"wast",
]
[[package]]
name = "wayland-backend"
version = "0.3.17"
@@ -5379,7 +5685,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"serde_json",
]
+11 -2
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.2.0"
version = "0.3.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
@@ -30,6 +30,9 @@ vectorcraft-ui-egui = { path = "crates/ui-egui" }
vectorcraft-mcp = { path = "crates/mcp" }
vectorcraft-testkit = { path = "crates/testkit" }
vectorcraft-cad = { path = "crates/cad" }
vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -41,6 +44,12 @@ log = "0.4"
proptest = "1"
# tests: a TCP port bound without listening (connections to it are refused)
socket2 = "0.6"
# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32
# too. Without `wat` (text parsing is test-only) and memory64; `validate` keeps full module
# validation on; the portable dispatcher can't overflow the host stack on long-running modules.
wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] }
# Tests: WebAssembly text -> binary for the plug-in fixtures (Apache-2.0 WITH LLVM-exception).
wat = "1.228"
# rendering / formats
vello_cpu = "0.2"
+8
View File
@@ -26,3 +26,11 @@ BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0).
JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is
based in part on the work of the Independent JPEG Group.
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
under the Apache License 2.0 with the LLVM exception.
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ
UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL
Open Font License 1.1. Their license texts ship with the release packages as OFL-<family>.txt.
+26 -2
View File
@@ -154,6 +154,10 @@ cd apps/vectorcraft-web && trunk build --release # web build → dist
cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names
```
Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
### Use it from Claude Code and other agents
Register the MCP server with Claude Code:
@@ -169,7 +173,25 @@ The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`]
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
milestones, and honest time-to-parity estimates.
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}`
**Where we are (2026-10-06):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
**What's missing:**
- 3D and Materials;
- the Photoshop-style raster effects (Effect Gallery);
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- Variables and scripting;
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- packaging for Windows and Linux.
**Where we're going:** next is the interaction-fidelity pass alongside the raster-effects package, then 3D and
advanced type, then hardening and packaging for 1.0. The prioritized list is in
[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order).
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
the UI can be swapped without touching the engine.
@@ -223,7 +245,9 @@ VectorCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-AP
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. Required notices are in [NOTICE](NOTICE).
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [ASSETS.md](ASSETS.md).
with its author, source and license in [ASSETS.md](ASSETS.md). Release builds also embed the
Japanese fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md)
(SIL Open Font License 1.1).
The app icon (an engraved dragon on VectorCraft red, `#e8573f`) is the owner's original artwork; its
palette and files are in [`assets/app-icon/`](assets/app-icon/README.md).
+114 -44
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-05._
_Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7; see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -12,12 +12,78 @@ _Last updated: 2026-10-05._
|---|---|
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
| Feature surface vs full Illustrator (weighted, see below) | **~68%** |
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~50%** |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~300–450 h** one agent · **~75–130 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~425–650 h** one agent · **~105–185 h** with 4–6 agents |
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents |
### Shipped so far
## Honest assessment (2026-10-05)
**In one line:** about two-thirds to three-quarters of Illustrator's features exist and work, and for everyday
vector illustration (drawing, paths, paint and appearance, SVG/PDF/print) VectorCraft is close to usable. Measured
against "a power user can't tell the difference", it is closer to 40–55%. What stands between here and "anyone can
switch" is a handful of large subsystems plus a fidelity pass, not a long tail of small fixes.
**How much to trust the numbers:** area scores are graded by the agents that built the features, from behaviour docs
(`plan/illustrator/`) and tests, not by side-by-side use of Illustrator. Treat them as upper bounds. Look & feel is
the only dimension measured against the reference app. Performance budgets haven't been re-run on an idle machine
since 2026-10-01.
### By dimension
| Dimension | Estimate | Evidence and what's missing |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type has initial support), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; Windows/Linux packaging and accessibility are pending |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
### Where we're lacking (in priority order)
Ordered by how much each gap blocks someone from switching. Sizes are one-agent hours from the parity table.
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
isolation mode. Do it side by side with Illustrator where the owner allows. 60–90 h.
2. **Photoshop-style raster effects and the Effect Gallery:** about 55 filters (Artistic, Brush Strokes, Distort,
Pixelate, Sketch, Stylize, Texture, Video) on the raster pipeline that drop shadows already use, applied at
Document Raster Effects Settings resolution. Parallelizes well across agents. 28–42 h.
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
The largest single gap. 50–80 h.
4. **Advanced type:** CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical font metrics,
mixed-script orientation), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
initial support. 35–48 h across type core and advanced.
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h.
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
rulers. 15–25 h.
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
browser QA, accessibility, packaging. 50–80 h.
### Where we're going
- **Next:** start the interaction-fidelity pass (1) in parallel with the raster-effects package (2). Both are broad,
so they split well across agents, and they move the "can't tell the difference" number most.
- **Then:** 3D (3) and advanced type (4), each a focused milestone with its own plan in `plan/` before coding.
- **Alongside:** keep closing the stubbed menu items. Run `vectorcraft-cli perf` on an idle machine, and fix any
budget it misses before 1.0.
- **1.0:** feature parity plus the fidelity pass, hardening (8) and packaging for all three desktop OSes and the web.
**For agents:** pick work from the list above (or the milestone rows below), and write a task plan before coding.
When a task lands, update this section, the parity table and "Shipped so far" in the same PR. Keep the scores honest:
grade by behaviour against `plan/illustrator/`, not by whether a menu item exists.
## Shipped so far
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
- **Automation:**
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
@@ -29,6 +95,7 @@ _Last updated: 2026-10-05._
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
@@ -36,7 +103,7 @@ _Last updated: 2026-10-05._
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
- **Cutting:** Eraser, Scissors, Knife.
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
@@ -44,10 +111,11 @@ _Last updated: 2026-10-05._
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- 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), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- 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), 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.
- **Japanese interface and vertical type:** VectorCraft ▸ Language switches the menus between English and Japanese (untranslated labels stay English) and persists; the bundled OFL Shippori Mincho gives Japanese glyphs without system fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs with `vert`/`vrt2` alternates); 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, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, 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):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
@@ -59,38 +127,40 @@ _Last updated: 2026-10-05._
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **Formats:**
- `.vectorcraft` (lossless JSON), SVG import/export, PDF export/import (including PDF-compatible `.ai`).
- PNG, JPEG and WebP export, Export for Screens, Place.
- **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, background save and export.
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, 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).
- 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.
- 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.
- **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.
- **Tests:** ~1,900 automated tests: model-based property tests, a junk-parameter sweep over every command, golden renders, and MCP end-to-end tests over stdio.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
## Milestones and estimates
| # | Milestone | Status | Est. remaining, one agent (h) |
|---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; flattener presets not yet used by PDF/print; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 one engine loader/encoder for every frontend (desktop, web, CLI, control channel, headless MCP open native/SVG/SVGZ/PDF/.ai/.ait/PNG/JPEG/GIF/WebP/TIFF/BMP; `document.formats`; MCP `export` takes artboard/range/options; one PDF page per artboard in Export for Screens; template layers left out of raster exports); Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); Save / Don't Save / Cancel before closing modified documents; PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool done, Trim View, middle-button pan; print tiling, multiple windows, presentation polish) | 🟡 | 20–33 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux and content-sized dialogs and menus done; accessibility, Windows/Linux packaging pending | 18–28 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
| | **Total to full parity** (one agent; sum of the rows above — matches [Parity estimate](#parity-estimate)) | | **~425–650** |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
## Parity estimate
_Method (2026-10-02, M3 rows re-derived 2026-10-04):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of
_Method (2026-10-02; M3 rows re-derived 2026-10-04, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of
real users' work) each represents. Each area is scored by depth of behaviour, not by presence of a menu item: an area
is 100% only when every feature in it behaves like Illustrator. Feature parity = Σ weight × score / Σ weight. Remaining
time is counted per area from what is missing, calibrated on measured throughput: in the last session one agent landed
@@ -102,40 +172,40 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 |
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder edge cases | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 |
| Appearance, transparency, graphic styles, masks | 5 | 97% | flattener presets in PDF/EPS/print, raster effect reach in preview bounds | 2–4 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 15% | ~55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Texture…), resolution setting, raster effects in PDF | 30–45 |
| 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 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish | 6–10 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, print tiling, Snap to Pixel/Glyph | 10–15 |
| Type advanced | 4 | 45% | CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical metrics, mixed-script orientation), 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 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
| File formats | 6 | 60% | EPS in/out, DXF/DWG, PSD placement, PDF raster effects/presets | 40–55 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 45% | Asset Export panel, slices, Save for Web | 12–18 |
| Print, colour management, separations, flattener | 3 | 40% | Print dialog (platform), flattener presets, print presets | 20–30 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 75% | ~14 panels (Tabs, Links, Attributes, Asset Export, SVG Interactivity, Variables, CSS Properties…), Properties per context | 20–30 |
| Libraries, Links, Package | 2 | 15% | Links panel, Package, local libraries | 10–15 |
| **Feature parity** | **103** | **~68%** | | **~300–450** |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done) | 15–25 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
| **Full parity** | | **~52%** | | **~425–650** |
| **Full parity** | | **~56%** | | **~320–500** |
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
(integration, review and shared files such as `menus.rs` serialize some work): **~75–130 h** to feature parity,
**~105–185 h** to full parity.
(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity,
**~80–145 h** to full parity.
_Inventories (2026-10-02):_ 426 engine commands + ~60 UI commands; 316 menu items wired, ~70 still disabled; 69 of 79
tools implemented (missing: Slice ×2, Touch Type, vertical type ×3, Print Tiling); 32 panels of ~45; 44 live
effects of ~110 (Illustrator effects 44/54, Photoshop-style raster effects 1/56); ~1,050 tests (~1,900 on 2026-10-05).
_Inventories (2026-10-05):_ 16 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
implemented except Touch Type; 51 panel modules; Illustrator-style live
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every
+2 -1
View File
@@ -16,6 +16,7 @@ path = "src/main.rs"
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-format = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
@@ -26,4 +27,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent", "Storage"] }
+107 -5
View File
@@ -6,7 +6,9 @@ use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::cmd::recovery::RecoveryStore;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
@@ -172,21 +174,61 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Servic
}
})),
inbox: Some(inbox),
recovery_store: Some(Arc::new(BrowserStore)),
// File → Print: the browser's print dialog.
print: Some(Box::new(BrowserPrint)),
..Default::default()
}
}
/// Data Recovery's store on the web: the browser's local storage (kept across visits and shared by
/// the site's tabs), each entry as base64 under [`RECOVERY_PREFIX`]`<area>/<name>`. It has no
/// locks: each tab holds its area with a heartbeat, judged by the browser's clock.
struct BrowserStore;
const RECOVERY_PREFIX: &str = "vectorcraft-recovery/";
fn js_err(e: wasm_bindgen::JsValue) -> String {
format!("{e:?}")
}
fn local_storage() -> Result<web_sys::Storage, String> {
web_sys::window().ok_or("no window")?.local_storage().map_err(js_err)?.ok_or_else(|| "browser storage is turned off".into())
}
impl RecoveryStore for BrowserStore {
fn list(&self) -> Result<Vec<String>, String> {
let s = local_storage()?;
let n = s.length().map_err(js_err)?;
Ok((0..n).filter_map(|i| s.key(i).ok().flatten()).filter_map(|k| k.strip_prefix(RECOVERY_PREFIX).map(str::to_string)).collect())
}
fn read(&self, name: &str) -> Result<Vec<u8>, String> {
let text = local_storage()?.get_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)?;
text.and_then(|t| vectorcraft_format::base64_decode(&t)).ok_or_else(|| format!("{name}: no such recovery entry"))
}
fn write(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
local_storage()?
.set_item(&format!("{RECOVERY_PREFIX}{name}"), &vectorcraft_format::base64_encode(bytes))
.map_err(|e| format!("browser storage is full or turned off: {}", js_err(e)))
}
fn remove(&self, name: &str) -> Result<(), String> {
local_storage()?.remove_item(&format!("{RECOVERY_PREFIX}{name}")).map_err(js_err)
}
fn location(&self) -> String {
"browser storage".into()
}
fn now(&self) -> Option<i64> {
Some((js_sys::Date::now() / 1000.0) as i64)
}
}
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "vectorcraft".into());
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(fileio::format_for_name(&name).map_or("application/octet-stream", |f| f.mime));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let url = blob_url(bytes, fileio::format_for_name(&name).map_or("application/octet-stream", |f| f.mime))?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(&name);
@@ -202,3 +244,63 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
/// An object URL of a blob of `bytes` with media type `mime` (revoke it when done).
fn blob_url(bytes: &[u8], mime: &str) -> Result<String, String> {
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime);
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js_err)?;
web_sys::Url::create_object_url_with_blob(&blob).map_err(js_err)
}
/// File → Print in the browser: the job's PDF in a hidden frame, printed through the browser's
/// print dialog (which picks the printer).
struct BrowserPrint;
/// The frame holding the last print job (the next job replaces it).
const PRINT_FRAME_ID: &str = "vectorcraft-print-frame";
impl PrintService for BrowserPrint {
fn printers(&mut self) -> Vec<Printer> {
// Browsers don't tell pages about printers: their print dialog lists them.
vec![]
}
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
print_pdf(job.pdf)?;
Ok(format!("Printing “{}”: pick the printer in the browser's print dialog", job.title))
}
}
fn print_pdf(pdf: &[u8]) -> Result<(), String> {
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
// The previous job's frame and its blob go first.
if let Some(old) = document.get_element_by_id(PRINT_FRAME_ID) {
if let Some(url) = old.get_attribute("src") {
web_sys::Url::revoke_object_url(&url).ok();
}
old.remove();
}
let url = blob_url(pdf, "application/pdf")?;
let frame: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js_err)?.dyn_into().map_err(|_| "not a frame")?;
frame.set_id(PRINT_FRAME_ID);
// Out of sight but laid out: browsers don't print a frame that isn't.
let style = frame.style();
for (k, v) in [("position", "fixed"), ("right", "0"), ("bottom", "0"), ("width", "0"), ("height", "0"), ("border", "0")] {
style.set_property(k, v).map_err(js_err)?;
}
let loaded = frame.clone();
let onload = wasm_bindgen::closure::Closure::once_into_js(move || {
if let Some(w) = loaded.content_window() {
w.focus().ok();
if let Err(e) = w.print() {
log::error!("printing failed: {e:?}");
}
}
});
frame.set_onload(Some(onload.unchecked_ref()));
frame.set_src(&url);
document.body().ok_or("no body")?.append_child(&frame).map_err(js_err)?;
Ok(())
}
+2
View File
@@ -22,6 +22,8 @@ rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
webbrowser = { workspace = true }
# The window's monitors, size and position (the same winit eframe runs on).
winit = { version = "0.30", default-features = false }
[target.'cfg(windows)'.dependencies]
clipboard-win = { workspace = true }
+39 -10
View File
@@ -5,11 +5,14 @@
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `vectorcraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
mod clipboard;
mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
mod printing;
mod window;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
@@ -29,6 +32,7 @@ impl eframe::App for App {
}
}
self.0.logic(ctx);
window::track(ctx, &mut self.0.ui.window);
if self.0.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
@@ -70,21 +74,32 @@ fn prefs_path_for(name: &str, lower: &str) -> Option<std::path::PathBuf> {
base.map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut VectorcraftApp) {
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
fn prefs_enabled() -> bool {
std::env::var_os("VECTORCRAFT_NO_PREFS").is_none()
}
/// The saved UI preferences, read before the window opens (they hold its size and position).
fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
if !prefs_enabled() {
return None;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
serde_json::from_slice(&bytes).ok()
}
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
if !prefs_enabled() {
return;
}
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()));
if let Some(bytes) = bytes
&& let Ok(ui) = serde_json::from_slice::<vectorcraft_ui_egui::UiState>(&bytes)
{
if let Some(ui) = saved {
app.ui = ui.sanitized();
}
vectorcraft_ui_egui::prefs_dialog::restore(app);
}
fn save_prefs(app: &VectorcraftApp) {
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
if !prefs_enabled() {
return;
}
if let Some(p) = prefs_path() {
@@ -176,6 +191,8 @@ fn services() -> Services {
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
}
}
@@ -227,11 +244,13 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
let saved = read_prefs();
let saved_window = saved.as_ref().and_then(|ui| ui.window);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_inner_size(window::DEFAULT_SIZE)
.with_min_inner_size(window::MIN_SIZE)
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
@@ -246,12 +265,22 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services());
load_prefs(&mut app);
load_prefs(&mut app, saved);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
app.ui.window = Some(window::restore(w, saved_window));
}
// User Defined swatch and graphic style libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
// Data Recovery copies live next to the preferences too (none for runs without
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
app.session.recovery.set_default_folder(recovery);
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = CUSTOM_TITLEBAR;
if let Some(port) = control_port {
+22 -9
View File
@@ -18,6 +18,7 @@ enum Handle {
}
pub struct NativeMenu {
language: vectorcraft_ui_egui::i18n::Language,
_menu: Menu,
items: HashMap<String, (String, Value, Handle, String)>,
last_refresh: f64,
@@ -25,6 +26,8 @@ pub struct NativeMenu {
generation: u64,
/// Saved views and recent files listed (empty slots are left out, so a change rebuilds).
slots: usize,
/// Plug-in registry revision the Plug-ins submenus were built for.
plugins: u64,
}
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
@@ -45,7 +48,7 @@ impl NativeMenu {
let mut items = HashMap::new();
let mut counter = 0usize;
for (title, entries) in menus::menu_tree() {
let sub = Submenu::new(title, true);
let sub = Submenu::new(app.ui.language.tr(title), true);
build(app, &sub, &entries, &mut items, &mut counter);
let _ = menu.append(&sub);
}
@@ -56,7 +59,15 @@ impl NativeMenu {
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
.map(|(_, _, _, c)| c.clone())
.collect();
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app) }
Self {
_menu: menu,
items,
last_refresh: 0.0,
generation,
slots: menus::listed_slots(app),
plugins: menus::plugin_revision(),
language: app.ui.language,
}
}
/// Dispatch clicked items and refresh state.
@@ -72,10 +83,12 @@ impl NativeMenu {
return;
}
self.last_refresh = now;
// Shortcuts edited, workspaces added, saved views or recent files changed: rebuild so
// Shortcuts edited, workspaces added, saved views, recent files or plug-ins changed: rebuild so
// accelerators and lists are current.
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|| menus::listed_slots(app) != self.slots
|| menus::plugin_revision() != self.plugins
|| app.ui.language != self.language
{
*self = NativeMenu::install(app);
return;
@@ -108,7 +121,7 @@ impl NativeMenu {
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
)
{
i.set_text(menus::dynamic_label(app, cmd, ""));
i.set_text(app.ui.language.tr(&menus::dynamic_label(app, cmd, "")));
}
}
Handle::Check(c) => {
@@ -133,13 +146,13 @@ fn build(
let _ = parent.append(&PredefinedMenuItem::separator());
}
Item::Header(h) => {
let _ = parent.append(&MenuItem::new(*h, false, None));
let _ = parent.append(&MenuItem::new(app.ui.language.tr(h), false, None));
}
Item::Todo(label, sc) => {
let _ = parent.append(&MenuItem::new(*label, false, accel(sc)));
let _ = parent.append(&MenuItem::new(app.ui.language.tr(label), false, accel(sc)));
}
Item::Sub(label, children) => {
let sub = Submenu::new(*label, true);
let sub = Submenu::new(app.ui.language.tr(label), true);
build(app, &sub, children, items, counter);
let _ = parent.append(&sub);
}
@@ -150,11 +163,11 @@ fn build(
let id = format!("dc{counter}");
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
let handle = if menus::checked(app, cmd, params).is_some() {
let c = CheckMenuItem::with_id(id.clone(), *label, true, false, sc);
let c = CheckMenuItem::with_id(id.clone(), app.ui.language.tr(label), true, false, sc);
let _ = parent.append(&c);
Handle::Check(c)
} else {
let i = MenuItem::with_id(id.clone(), *label, true, sc);
let i = MenuItem::with_id(id.clone(), app.ui.language.tr(label), true, sc);
let _ = parent.append(&i);
Handle::Plain(i)
};
+207
View File
@@ -0,0 +1,207 @@
//! File → Print on the desktop ([`SystemPrint`]): the system's printers and print queue. macOS and
//! Linux go through CUPS (`lpstat` lists the printers, `lp` queues a temporary copy of the job's
//! PDF); Windows asks PowerShell for the printers and prints through the shell's print verb of
//! the PDF viewer.
use std::path::PathBuf;
use std::process::Command;
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
/// The system's printing.
pub struct SystemPrint;
impl PrintService for SystemPrint {
fn printers(&mut self) -> Vec<Printer> {
system::printers()
}
fn print(&mut self, job: &PrintJob) -> Result<String, String> {
let file = job_file(job.pdf)?;
let r = system::print(job, &file);
let to = job.printer.unwrap_or("the default printer");
r.map(|note| format!("Sent “{}” to {to}{note}", job.title))
}
fn has_setup(&self) -> bool {
true
}
fn setup(&mut self, printer: Option<&str>) -> Result<(), String> {
system::setup(printer)
}
}
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
/// than an hour old (the print verb reads them after Print returns) are removed first.
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join("vectorcraft-print");
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
if old {
// Another job may still be reading it: leave it for next time.
let _ = std::fs::remove_file(e.path());
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
std::fs::write(&path, pdf).map_err(|e| format!("can't write {}: {e}", path.display()))?;
Ok(path)
}
/// Run `c` → its output, or its error message.
fn output(c: &mut Command) -> Result<String, String> {
let program = c.get_program().to_string_lossy().to_string();
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
}
/// `lpstat -e` / `lpstat -a` → the printer names (the first word of each line).
#[cfg(any(test, not(windows)))]
fn lpstat_names(text: &str) -> Vec<String> {
let mut names: Vec<String> = vec![];
for name in text.lines().filter_map(|l| l.split_whitespace().next()) {
if !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
}
names
}
/// `lpstat -d` → the default printer ("system default destination: NAME"; none: "no system
/// default destination").
#[cfg(any(test, not(windows)))]
fn lpstat_default(text: &str) -> Option<String> {
let line = text.lines().find(|l| l.contains(':'))?;
let name = line.rsplit_once(':')?.1.trim();
(!name.is_empty()).then(|| name.to_string())
}
/// PowerShell's `<Default>\t<Name>` lines → the printers.
#[cfg(any(test, windows))]
fn windows_printers(text: &str) -> Vec<Printer> {
text.lines()
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
.filter(|(_, name)| !name.trim().is_empty())
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
.collect()
}
/// `s` as a PowerShell single-quoted string.
#[cfg(any(test, windows))]
fn ps_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
#[cfg(not(windows))]
mod system {
use super::*;
pub fn printers() -> Vec<Printer> {
// `-e` lists every destination (CUPS 2); older systems answer `-a`.
let names = output(Command::new("lpstat").arg("-e")).or_else(|_| output(Command::new("lpstat").arg("-a"))).map(|t| lpstat_names(&t));
let default = output(Command::new("lpstat").arg("-d")).ok().and_then(|t| lpstat_default(&t));
names.unwrap_or_default().into_iter().map(|name| Printer { default: default.as_ref() == Some(&name), name }).collect()
}
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
let mut c = Command::new("lp");
if let Some(p) = job.printer {
c.args(["-d", p]);
}
c.args(["-t", job.title]).arg(file);
let r = output(&mut c);
// `lp` has spooled its own copy (or failed).
let _ = std::fs::remove_file(file);
r.map(|out| Some(out.trim().to_string()).filter(|o| !o.is_empty()).map(|o| format!(" ({o})")).unwrap_or_default())
}
pub fn setup(printer: Option<&str>) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let _ = printer;
Command::new("open")
.arg("x-apple.systempreferences:com.apple.Print-Scan-Settings.extension")
.spawn()
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "macos"))]
{
if Command::new("system-config-printer").spawn().is_ok() {
return Ok(());
}
// CUPS' own pages.
let url = printer.map_or_else(|| "http://localhost:631/printers/".to_string(), |p| format!("http://localhost:631/printers/{p}"));
Command::new("xdg-open").arg(url).spawn().map(|_| ()).map_err(|e| e.to_string())
}
}
}
#[cfg(windows)]
mod system {
use super::*;
fn powershell(script: &str) -> Command {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-NonInteractive", "-Command", script]).creation_flags(CREATE_NO_WINDOW);
c
}
pub fn printers() -> Vec<Printer> {
let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
}
pub fn print(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
let path = ps_quote(&file.to_string_lossy());
let script = match job.printer {
Some(p) => format!(
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
ps_quote(&format!("\"{}\"", p.replace('"', "")))
),
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
};
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
}
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lpstat_lists_names_and_the_default() {
let names = lpstat_names("Office_Laser accepting requests since Mon 05 Oct 2026\nPhoto-Inkjet accepting requests since …\n\n");
assert_eq!(names, ["Office_Laser", "Photo-Inkjet"]);
assert_eq!(lpstat_names("Office_Laser\nOffice_Laser\n"), ["Office_Laser"]);
assert_eq!(lpstat_default("system default destination: Photo-Inkjet\n").as_deref(), Some("Photo-Inkjet"));
assert_eq!(lpstat_default("no system default destination\n"), None);
}
#[test]
fn powershell_lines_become_printers() {
let got = windows_printers("False\tMicrosoft Print to PDF\r\nTrue\tOffice Laser\r\n\r\nFalse\t \r\n");
assert_eq!(got, [Printer { name: "Microsoft Print to PDF".into(), default: false }, Printer { name: "Office Laser".into(), default: true }]);
assert_eq!(ps_quote("C:\\Temp\\it's.pdf"), "'C:\\Temp\\it''s.pdf'");
}
#[test]
fn job_files_hold_the_pdf() {
let path = job_file(b"%PDF-1.7 test").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
let _ = std::fs::remove_file(path);
}
}
+256
View File
@@ -0,0 +1,256 @@
//! The main window's size and position. The first launch fits the window to its monitor, opening
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
//! onto a monitor that is still connected.
use vectorcraft_ui_egui::state::WindowGeometry;
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
/// The first window's size, in points.
pub const DEFAULT_SIZE: [f32; 2] = [1440.0, 900.0];
/// The smallest window, in points.
pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
/// say how much of the screen they take): a monitor with less room opens it maximized.
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Monitor {
pos: [i64; 2],
size: [i64; 2],
scale: f32,
}
impl Monitor {
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
let scale = scale as f32;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
}
/// Its size in points.
fn points(&self) -> [f32; 2] {
self.size.map(|s| s as f32 / self.scale)
}
/// Its size in points less room for a taskbar, Dock or menu bar.
fn usable(&self) -> [f32; 2] {
let points = self.points();
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
}
/// A window of `size` points on this monitor, in physical pixels.
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
size.map(|s| (s * self.scale) as i64)
}
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
let px = self.pixels(size);
let span = |i: usize| {
let start = i64::from(pos[i]).max(self.pos[i]);
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
(end - start).max(0)
};
span(0).saturating_mul(span(1))
}
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
/// points on this monitor.
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
let px = self.pixels(size);
std::array::from_fn(|i| {
let lo = self.pos[i];
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
})
}
}
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
/// doesn't list them, so nothing to check a saved geometry against) the default size.
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
return default;
};
let Some(saved) = saved else {
let usable = home.usable();
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
return default;
}
// A small screen: maximized, with a size that fits once un-maximized.
let size = fit(DEFAULT_SIZE, usable);
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
};
// The monitor showing most of the window; none when it was on a monitor that is gone.
let shown = saved.pos.and_then(|p| {
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
(area > 0).then_some(m)
});
let m = shown.unwrap_or(home);
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
let room = m.points();
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
let size = fit(saved.size, if too_big { m.usable() } else { room });
WindowGeometry {
// Moved fully onto its monitor, or centred on the home monitor.
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
size,
maximized: saved.maximized || too_big,
}
}
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry.
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
let g = place(saved, &monitors, home);
if let Some([x, y]) = g.pos {
window.set_outer_position(PhysicalPosition::new(x, y));
}
// Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
if g.maximized {
window.set_maximized(true);
}
g
}
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
let zoom = ctx.zoom_factor();
ctx.input(|i| {
let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) {
return;
}
if v.maximized == Some(true) {
if let Some(g) = geometry {
g.maximized = true;
}
return;
}
let Some(inner) = v.inner_rect else { return };
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
});
}
#[cfg(test)]
mod tests {
use super::*;
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
}
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
Some(WindowGeometry { pos, size, maximized })
}
#[test]
fn the_first_window_fits_its_screen() {
// 1920 × 1080: the default size, where the system puts it.
let big = [monitor(0, 0, 1920, 1080, 1.0)];
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
// 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points): maximized, and a
// size that fits the screen with room for the taskbar once un-maximized.
for (m, size) in [
(monitor(0, 0, 1600, 900, 1.0), [1440.0, 772.0]),
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
] {
let g = place(None, &[m], Some(0));
assert!(g.maximized, "{m:?}");
assert_eq!(g.size, size, "{m:?}");
let [x, y] = g.pos.unwrap();
let px = m.pixels(g.size);
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
}
// No monitor information: the default size, whatever was saved.
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
}
#[test]
fn the_saved_window_comes_back_where_it_was() {
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)]
{
assert_eq!(Some(place(g, &two, Some(0))), g);
}
}
#[test]
fn a_saved_window_stays_on_a_connected_monitor() {
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
// Last time on a second monitor that is gone: centred on the remaining one.
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
// Half off the monitor's right and bottom edges: moved fully onto it.
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
assert_eq!(g.pos, Some([600, 200]));
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
// A monitor left of the primary one (negative coordinates) still counts.
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
// Nonsense sizes from a damaged preferences file are kept within the screen.
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
}
#[test]
fn the_window_geometry_is_tracked_for_the_next_launch() {
let ctx = egui::Context::default();
let mut geometry = None;
let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
geometry
};
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
let normal = egui::ViewportInfo {
native_pixels_per_point: Some(1.5),
inner_rect: Some(rect),
outer_rect: Some(rect),
maximized: Some(false),
..Default::default()
};
// Position in physical pixels, size in points.
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
assert_eq!(frame(normal.clone()), Some(g));
// Maximized or minimized: the size to restore stays.
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
assert_eq!(frame(normal), Some(g));
// Saved with the preferences; older preference files have no window.
let mut ui = vectorcraft_ui_egui::UiState { window: Some(g), ..Default::default() };
let back: vectorcraft_ui_egui::UiState = serde_json::from_slice(&serde_json::to_vec(&ui).unwrap()).unwrap();
assert_eq!(back.window, Some(g));
ui.window = None;
let json = serde_json::to_value(&ui).unwrap();
assert!(json.get("window").is_none());
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
assert_eq!(back.window, None);
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21L21 3" stroke-dasharray="2 2"/><path d="M4 4H16.5L4 16.5Z" fill="currentColor" fill-opacity="0.35"/><path d="M20 7.5V20H7.5Z" fill="currentColor" fill-opacity="0.35"/></svg>

After

Width:  |  Height:  |  Size: 371 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2V22" stroke-dasharray="2 2"/><path d="M12 5C7.5 5 4 8 4 12C4 16 7.5 19 12 19Z" fill="currentColor" fill-opacity="0.35"/><path d="M12 5C16.5 5 20 8 20 12C20 16 16.5 19 12 19" stroke-opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 400 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="14" height="16" stroke-dasharray="2 2"/><path d="M17 6.34A6 6 0 1 0 17 17.66Z" fill="currentColor" fill-opacity="0.35"/><path d="M17 6.34A6 6 0 0 1 17 17.66" stroke-opacity="0.5"/></svg>

After

Width:  |  Height:  |  Size: 395 B

+218
View File
@@ -0,0 +1,218 @@
//! DXF import: an ASCII DXF drawing of any version as a document.
//!
//! The drawing's model space (or a paper layout) comes in at a ratio (1 unit of the art = N
//! drawing units; by default the drawing's own unit at 1:1) or fitted to an artboard, centred
//! or with the drawing's origin on the artboard's bottom-left corner. Each DXF layer holding art
//! becomes a layer (hidden, frozen, locked and non-plotting layers keep that), or all art goes on
//! one. Lines, polylines with arc segments, circles, arcs, ellipses, splines (NURBS), solid
//! hatches and solids become paths; text and multiline text point type; named blocks symbols
//! (anonymous ones, such as dimensions, groups of their art). Colours (indexed and true colour),
//! lineweights, linetypes and transparency come through layers and blocks as CAD apps resolve
//! them. What can't come in is listed in the warnings.
//!
//! - `reader`: the file's text, group codes, sections, tables, blocks and layouts.
//! - `entity`: entities to items (geometry and properties).
//! - `curve`: bulges, arcs, ellipses and NURBS as Bézier paths.
//! - `text`: text values to plain text.
//! - `build`: items to the document.
mod build;
mod curve;
mod entity;
mod reader;
mod text;
use vectorcraft_doc::{Document, Unit};
pub use reader::BINARY_SENTINEL;
use crate::{DxfVersion, INSUNITS, SCALE_RANGE};
/// Letter, the box Fit to Artboard fills unless told otherwise (points).
pub const FIT_TO: (f64, f64) = (612.0, 792.0);
/// Why a binary DXF can't be opened.
pub const BINARY: &str = "this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that";
/// How a drawing comes in.
#[derive(Clone, Debug, PartialEq)]
pub struct ImportOptions {
/// The layout (any case): `Model` (`None`, the default) or a paper layout's name.
pub layout: Option<String>,
/// Scale the art to fit `fit_to` (its larger side along the art's), instead of the ratio.
pub fit: bool,
/// The artboard Fit fills (points, `width × height`).
pub fit_to: (f64, f64),
/// The ratio: 1 `unit` of the art is `scale` drawing units. `None`: the drawing's own unit
/// at 1:1 (millimetres for unitless metric drawings, inches for imperial ones).
pub unit: Option<Unit>,
pub scale: Option<f64>,
/// Lineweights scale with the art (else they keep their printed widths).
pub scale_lineweights: bool,
/// The art's centre on the artboard's (else the drawing's origin, or fitted art's
/// bottom-left corner, on the artboard's bottom-left corner).
pub center: bool,
/// All art on one layer.
pub merge_layers: bool,
}
impl Default for ImportOptions {
fn default() -> Self {
Self { layout: None, fit: false, fit_to: FIT_TO, unit: None, scale: None, scale_lineweights: false, center: true, merge_layers: false }
}
}
/// An imported drawing.
#[derive(Clone, Debug)]
pub struct Imported {
pub document: Document,
/// What was approximated or left out.
pub warnings: Vec<String>,
}
/// What a drawing holds, for the import options.
#[derive(Clone, Debug, PartialEq)]
pub struct DxfInfo {
/// The DXF version (`2018`, `R12`…; the `$ACADVER` code when it's none of the known ones).
pub version: String,
/// The drawing unit's name (`Millimeters`, `Unitless`…).
pub units: String,
/// `Model`, then the paper layouts.
pub layouts: Vec<String>,
/// The layer table's layers.
pub layers: Vec<String>,
/// The default ratio: 1 `unit` = `scale` drawing units.
pub unit: Unit,
pub scale: f64,
}
/// Does `bytes` look like a DXF file (ASCII or binary)?
pub fn is_dxf(bytes: &[u8]) -> bool {
if bytes.starts_with(reader::BINARY_SENTINEL) {
return true;
}
let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(1024)).unwrap_or(bytes));
let mut lines = head.trim_start_matches('\u{feff}').lines().map(str::trim).skip_while(|l| l.is_empty());
// Comments (999) may come first; then the first section.
while let (Some(code), Some(value)) = (lines.next(), lines.next()) {
match code {
"999" => continue,
"0" => return value == "SECTION",
_ => return false,
}
}
false
}
/// The pairs of a DXF file and where it is damaged (binary and DWG files are refused).
fn text(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, String> {
if bytes.starts_with(reader::BINARY_SENTINEL) {
return Err(BINARY.into());
}
if bytes.starts_with(b"AC10") || bytes.starts_with(b"AC1.") {
return Err("this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that".into());
}
Ok(reader::decode(bytes))
}
/// The unit of `$INSUNITS` code `code` and the scale that shows the drawing 1:1 in it.
fn default_ratio(h: &reader::Header) -> (Unit, f64) {
if let Some((_, u)) = INSUNITS.iter().find(|(c, _)| i64::from(*c) == h.insunits) {
return (*u, 1.0);
}
match build::insunits_points(h.insunits) {
Some(pt) => (Unit::Millimeters, Unit::Millimeters.points() / pt),
None if h.measurement == Some(0) => (Unit::Inches, 1.0),
None => (Unit::Millimeters, 1.0),
}
}
/// The name of `$INSUNITS` code `code`.
fn units_name(code: i64) -> &'static str {
const NAMES: [&str; 22] = [
"Unitless",
"Inches",
"Feet",
"Miles",
"Millimeters",
"Centimeters",
"Meters",
"Kilometers",
"Microinches",
"Mils",
"Yards",
"Angstroms",
"Nanometers",
"Microns",
"Decimeters",
"Decameters",
"Hectometers",
"Gigameters",
"Astronomical units",
"Light years",
"Parsecs",
"US survey feet",
];
usize::try_from(code).ok().and_then(|i| NAMES.get(i)).copied().unwrap_or("Unitless")
}
/// Read what `bytes` holds: its version, units, layouts and layers.
pub fn info(bytes: &[u8]) -> Result<DxfInfo, String> {
let text = text(bytes)?;
let (pairs, _) = reader::pairs(&text)?;
let d = reader::Drawing::read(&pairs);
if d.sections == 0 {
return Err("not a DXF drawing".into());
}
let h = &d.header;
let (unit, scale) = default_ratio(h);
let version = DxfVersion::ALL.into_iter().find(|v| v.acadver() == h.acadver).map_or_else(|| h.acadver.clone(), |v| v.id().to_string());
Ok(DxfInfo {
version,
units: units_name(h.insunits).into(),
layouts: d.layouts.iter().map(|l| l.name.clone()).collect(),
layers: d.layers.iter().map(|l| l.name.clone()).collect(),
unit,
scale,
})
}
/// Import an ASCII DXF drawing with `o`.
pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
let (w, h) = o.fit_to;
if !(w > 0.0 && h > 0.0 && w.max(h) <= build::MAX_EXTENT) {
return Err(format!("the artboard to fit must be a positive size up to {} pt", build::MAX_EXTENT));
}
if let Some(s) = o.scale
&& !(s.is_finite() && SCALE_RANGE.contains(&s))
{
return Err(format!("scale must be a number from {} to {}, not {s}", SCALE_RANGE.start(), SCALE_RANGE.end()));
}
let text = text(bytes)?;
let (pairs, damaged) = reader::pairs(&text)?;
let d = reader::Drawing::read(&pairs);
if d.sections == 0 {
return Err("not a DXF drawing".into());
}
let layout = match &o.layout {
None => d.layouts.first(),
Some(name) => d.layout(name),
};
let Some(layout) = layout else {
let names: Vec<&str> = d.layouts.iter().map(|l| l.name.as_str()).collect();
return Err(format!("the drawing has no layout `{}` (it has {})", o.layout.as_deref().unwrap_or_default(), names.join(", ")));
};
let (unit, scale) = default_ratio(&d.header);
let natural = unit.points() / scale;
let ratio = match (o.unit, o.scale) {
(None, None) => natural,
(u, s) => u.unwrap_or(unit).points() / s.unwrap_or(1.0),
};
let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?;
document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) };
warnings.extend(damaged);
Ok(Imported { document, warnings })
}
#[cfg(test)]
mod tests;
+615
View File
@@ -0,0 +1,615 @@
//! Drawing items to a document: the scale and position the options ask for, one layer per DXF
//! layer that holds art (or one in all), named blocks as symbols (anonymous ones, such as
//! dimensions, as groups), and colours, lineweights, linetypes and transparency resolved through
//! layers and blocks.
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use kurbo::BezPath;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Dash, Document, LAYER_COLORS, LayerColor, LineCap, Node, NodeKind, Symbol, TextObject};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
use super::ImportOptions;
use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, Span, TextItem};
use super::reader::{Drawing, LayoutDef};
use crate::{CAP_HEIGHT, MAX_NEST};
/// Points per millimetre.
const PT_PER_MM: f64 = 72.0 / 25.4;
/// The stroke weight of lineweight 0 (CAD apps draw it one device pixel wide), in points.
const THINNEST: f64 = 0.25;
/// The most objects an import makes.
const MAX_NODES: usize = 2_000_000;
/// How far from the origin art may lie (points): the canvas.
pub(crate) const MAX_EXTENT: f64 = 4.0e6;
const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are left out";
const XREFS: &str = "blocks referencing other drawings (external references) are left out";
const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out";
/// What a block's art takes from the insert that places it: its colour, lineweight, linetype
/// and opacity where the art says "by block", and its layer for art on layer 0.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
struct Inherit {
color: Option<[u8; 3]>,
lineweight: Option<i32>,
linetype: Option<String>,
alpha: Option<u8>,
layer: Option<String>,
}
pub(crate) struct Build<'d, 'a> {
d: &'d Drawing<'a>,
o: &'d ImportOptions,
conv: Converter,
doc: Document,
/// Block items by upper-case name.
blocks: HashMap<String, Rc<Vec<Item>>>,
bounds: HashMap<String, Option<Rect>>,
sensitive: HashMap<String, bool>,
/// Symbol names by block and what it inherits (`None`: the block has no art).
symbols: HashMap<(String, Inherit), Option<String>>,
/// Blocks being drawn, outermost first.
stack: Vec<String>,
/// Document space per drawing unit, and its symbols' (no translation).
map: Affine,
symbol_map: Affine,
/// Lineweights are multiplied by this.
lw_scale: f64,
nodes: usize,
warnings: Vec<String>,
}
/// 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;
Some(match code {
1 => 72.0,
2 => 864.0,
3 => 72.0 * 63_360.0,
4 => PT_PER_MM,
5 => PT_PER_MM * 10.0,
6 => m,
7 => m * 1e3,
8 => 72e-6,
9 => 72e-3,
10 => 2592.0,
11 => m * 1e-10,
12 => m * 1e-9,
13 => m * 1e-6,
14 => m * 0.1,
15 => m * 10.0,
16 => m * 100.0,
17 => m * 1e9,
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,
_ => return None,
})
}
impl<'d, 'a> Build<'d, 'a> {
pub fn new(d: &'d Drawing<'a>, o: &'d ImportOptions) -> Self {
Self {
d,
o,
conv: Converter::default(),
doc: Document::new(1.0, 1.0),
blocks: HashMap::new(),
bounds: HashMap::new(),
sensitive: HashMap::new(),
symbols: HashMap::new(),
stack: vec![],
map: Affine::IDENTITY,
symbol_map: Affine::IDENTITY,
lw_scale: 1.0,
nodes: 0,
warnings: vec![],
}
}
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
/// Import `layout`; `natural` and `ratio` are points per drawing unit in the drawing's own
/// unit and at the scale asked for (fitting computes its own).
pub fn run(mut self, layout: &LayoutDef, natural: f64, ratio: f64) -> Result<(Document, Vec<String>), String> {
let items = self.conv.items(&self.d.layout_records(layout));
let bounds = items.iter().fold(None, |acc, it| vectorcraft_geom::union_opt(acc, self.item_bounds(it, 0)));
let (k, size, offset) = self.placement(bounds, natural, ratio)?;
self.map = Affine::new([k, 0.0, 0.0, -k, offset.x, offset.y]);
self.symbol_map = Affine::new([k, 0.0, 0.0, -k, 0.0, 0.0]);
self.lw_scale = if self.o.scale_lineweights { k / natural } else { 1.0 };
let layers = self.layers(&items);
let Self { mut doc, conv, warnings: notes, .. } = self;
if !layers.is_empty() {
doc.layers = layers;
}
if let Some(a) = doc.artboards.first_mut() {
a.rect = Rect::from_origin_size(Point::ZERO, size);
}
doc.fix_next_id();
let mut warnings = conv.warnings;
warnings.extend(notes);
if !conv.skipped.is_empty() {
let list: Vec<String> = conv.skipped.iter().map(|(k, n)| format!("{n} {k}")).collect();
warnings.push(format!("entities of these types are left out: {}", list.join(", ")));
}
if bounds.is_none() {
warnings.push(format!("the {} layout has no art", layout.name));
}
Ok((doc, warnings))
}
/// Points per drawing unit, the artboard's size, and where the drawing's origin goes.
fn placement(&self, bounds: Option<Rect>, natural: f64, ratio: f64) -> Result<(f64, vectorcraft_geom::Size, Point), String> {
let b = bounds.unwrap_or(Rect::ZERO);
let k = if self.o.fit {
let (w, h) = self.fit_box(b);
let ratio = |a: f64, b: f64| (b > 1e-12).then(|| a / b);
[ratio(w, b.width()), ratio(h, b.height())].into_iter().flatten().reduce(f64::min).unwrap_or(natural)
} else {
ratio
};
if !(k.is_finite() && k > 0.0) {
return Err("the scale is not a usable number".into());
}
let size = if self.o.fit {
let (w, h) = self.fit_box(b);
vectorcraft_geom::Size::new(w, h)
} else {
vectorcraft_geom::Size::new((b.width() * k).max(1.0), (b.height() * k).max(1.0))
};
// Centred: the art's centre on the artboard's; else the drawing's origin (fitted art: its
// bottom-left corner) on the artboard's bottom-left corner.
let offset = if self.o.center {
Point::new(size.width / 2.0 - b.center().x * k, size.height / 2.0 + b.center().y * k)
} else if self.o.fit {
Point::new(-b.x0 * k, size.height + b.y0 * k)
} else {
Point::new(0.0, size.height)
};
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)].map(|p| Point::new(p.x * k + offset.x, -p.y * k + offset.y));
let fits = corners.iter().all(|p| p.x.abs() <= MAX_EXTENT && p.y.abs() <= MAX_EXTENT) && size.width.max(size.height) <= MAX_EXTENT;
if !fits {
let (w, h) = (b.width() * k, b.height() * k);
return Err(format!(
"at this scale the drawing is {w:.0} × {h:.0} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard"
));
}
Ok((k, size, offset))
}
/// The box Fit to Artboard fills, turned to the art's orientation.
fn fit_box(&self, b: Rect) -> (f64, f64) {
let (w, h) = self.o.fit_to;
if (b.width() > b.height()) == (w > h) { (w, h) } else { (h, w) }
}
// ---------- blocks ----------
/// A block's items (read once).
fn block(&mut self, name: &str) -> Option<Rc<Vec<Item>>> {
if let Some(b) = self.blocks.get(name) {
return Some(b.clone());
}
let def = self.d.blocks.get(name)?;
if def.xref {
self.warn(XREFS);
return None;
}
let items = Rc::new(self.conv.items(&def.records));
self.blocks.insert(name.to_string(), items.clone());
Some(items)
}
/// Enter block `name` `depth` deep; false (with a warning) when too deep or inside itself.
fn enter(&mut self, name: &str, depth: u32) -> bool {
if depth >= MAX_NEST || self.stack.iter().any(|s| s == name) {
self.warn(NESTED);
return false;
}
self.stack.push(name.to_string());
true
}
/// A block's bounds in its coordinates less its base point.
fn block_bounds(&mut self, name: &str, depth: u32) -> Option<Rect> {
if let Some(b) = self.bounds.get(name) {
return *b;
}
let items = self.block(name)?;
if !self.enter(name, depth) {
return None;
}
let b = items.iter().fold(None, |acc, it| vectorcraft_geom::union_opt(acc, self.item_bounds(it, depth + 1)));
self.stack.pop();
let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base);
let b = b.map(|r| r - base.to_vec2());
self.bounds.insert(name.to_string(), b);
b
}
/// An item's bounds in world coordinates (type: an estimate from its height and length).
fn item_bounds(&mut self, it: &Item, depth: u32) -> Option<Rect> {
match &it.geom {
Geom::Path { bp, .. } => super::curve::bounds(bp),
Geom::Text(t) => {
let lines = t.text.lines().count().max(1) as f64;
let longest = t.text.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64;
let natural = longest * t.height * 0.9 * t.width;
// Fit and aligned text run their span; aligned text's height scales with it.
let k = t.span.and_then(|sp| sp.scale(natural)).unwrap_or(1.0);
let height = if matches!(t.span, Some(Span::Aligned(_))) { t.height * k } else { t.height };
let w = natural * k;
let x0 = match t.justify {
vectorcraft_doc::Justify::Center => -w / 2.0,
vectorcraft_doc::Justify::Right => -w,
_ => 0.0,
};
let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + height * 0.3;
Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, height)))
}
Geom::Insert(ins) => {
let b = self.block_bounds(&ins.block, depth);
let cells = b.into_iter().flat_map(|b| ins.cells.iter().map(move |m| m.transform_rect_bbox(b)));
let attribs: Vec<Option<Rect>> = ins.attribs.iter().map(|a| self.item_bounds(a, depth)).collect();
cells.map(Some).chain(attribs).fold(None, vectorcraft_geom::union_opt)
}
}
.filter(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()))
}
/// Does a block's look depend on its insert (art "by block", or on layer 0 "by layer")?
fn is_sensitive(&mut self, name: &str, depth: u32) -> bool {
if let Some(s) = self.sensitive.get(name) {
return *s;
}
let Some(items) = self.block(name) else { return false };
if !self.enter(name, depth) {
return false;
}
let mut s = false;
for it in items.iter() {
let p = &it.props;
let on_zero = p.layer == "0";
s |= p.color == Col::ByBlock || p.lineweight == Lw::ByBlock || p.linetype == "BYBLOCK" || p.alpha == Alpha::ByBlock;
s |= on_zero && (p.color == Col::ByLayer || p.lineweight == Lw::ByLayer || p.linetype == "BYLAYER");
if let Geom::Insert(ins) = &it.geom {
s |= !ins.attribs.is_empty() || self.is_sensitive(&ins.block, depth + 1);
}
if s {
break;
}
}
self.stack.pop();
self.sensitive.insert(name.to_string(), s);
s
}
// ---------- properties ----------
/// The layer art on layer 0 takes from its block, else its own.
fn layer_name<'p>(&self, p: &'p Props, inh: &'p Inherit) -> &'p str {
match &inh.layer {
Some(l) if p.layer == "0" => l,
_ => &p.layer,
}
}
fn rgb(&self, p: &Props, inh: &Inherit) -> [u8; 3] {
match p.color {
Col::Rgb(c) => c,
Col::ByBlock => inh.color.unwrap_or([0; 3]),
Col::ByLayer => self.d.layer(self.layer_name(p, inh)).map_or([0; 3], |l| l.rgb),
}
}
/// The lineweight in hundredths of a millimetre.
fn lineweight(&self, p: &Props, inh: &Inherit) -> i32 {
let default = self.d.header.lwdefault;
match p.lineweight {
Lw::Hundredths(v) => v,
Lw::Default => default,
Lw::ByBlock => inh.lineweight.unwrap_or(default),
Lw::ByLayer => self.d.layer(self.layer_name(p, inh)).map(|l| l.lineweight).filter(|v| *v >= 0).unwrap_or(default),
}
}
/// The linetype name (upper case) after "by layer" and "by block".
fn linetype(&self, p: &Props, inh: &Inherit) -> String {
match p.linetype.as_str() {
"BYLAYER" => self.d.layer(self.layer_name(p, inh)).map_or_else(|| "CONTINUOUS".into(), |l| l.linetype.clone()),
"BYBLOCK" => inh.linetype.clone().unwrap_or_else(|| "CONTINUOUS".into()),
other => other.to_string(),
}
}
fn opacity(&self, p: &Props, inh: &Inherit) -> f32 {
match p.alpha {
Alpha::Opacity(a) => a,
Alpha::ByBlock => inh.alpha.map_or(1.0, |a| f32::from(a) / 255.0),
Alpha::ByLayer => 1.0,
}
}
/// What a block placed by an insert with `p` inherits.
fn inherit(&self, p: &Props, inh: &Inherit) -> Inherit {
Inherit {
color: Some(self.rgb(p, inh)),
lineweight: Some(self.lineweight(p, inh)),
linetype: Some(self.linetype(p, inh)),
alpha: Some((self.opacity(p, inh) * 255.0).round() as u8),
layer: Some(self.layer_name(p, inh).to_string()),
}
}
/// A dash pattern in points for `linetype` drawn `scale` points per drawing unit.
fn dash(&self, linetype: &str, p: &Props, scale: f64) -> Option<(Dash, bool)> {
let pattern = self.d.linetypes.get(linetype)?;
let k = self.d.header.ltscale * p.ltscale * scale;
// Dashes (positive), gaps (negative) and dots (0), merged into dash, gap, dash, gap…
let mut out: Vec<f64> = vec![];
for v in pattern {
let dash = *v >= 0.0;
let len = v.abs() * k;
match out.len().is_multiple_of(2) {
expects_dash if expects_dash == dash => out.push(len),
_ if out.is_empty() => out.extend([0.0, len]),
_ => {
if let Some(last) = out.last_mut() {
*last += len;
}
}
}
}
if !out.len().is_multiple_of(2) {
out.push(0.0);
}
let dots = out.iter().step_by(2).any(|d| *d <= 1e-9);
let d = Dash { pattern: out, offset: 0.0, align_corners: false };
(d.is_dashed() && d.pattern.iter().skip(1).step_by(2).any(|g| *g > 1e-9) && d.pattern.iter().all(|v| v.is_finite())).then_some((d, dots))
}
// ---------- art ----------
/// One doc layer per DXF layer holding art (in the order art first uses them), or one for all.
fn layers(&mut self, items: &[Item]) -> Vec<Arc<Node>> {
let mut layers: Vec<(String, Vec<Arc<Node>>)> = vec![];
let mut index: HashMap<String, usize> = HashMap::new();
let top = Inherit::default();
let map = self.map;
for it in items {
let Some(node) = self.item(it, map, &top, 0) else { continue };
let name = if self.o.merge_layers { "" } else { it.props.layer.as_str() };
let i = *index.entry(name.to_uppercase()).or_insert_with(|| {
layers.push((name.to_string(), vec![]));
layers.len() - 1
});
if let Some((_, children)) = layers.get_mut(i) {
children.push(Arc::new(node));
}
}
layers
.into_iter()
.enumerate()
.map(|(i, (name, children))| {
let id = self.doc.alloc_id();
let color = LayerColor::Preset((i % LAYER_COLORS.len()) as u8);
let label = if name.is_empty() { "Layer 1" } else { &name };
let mut l = Node::layer(id, label, color);
if let Some(def) = self.d.layer(&name) {
l.visible = !def.hidden;
l.locked = def.locked;
if let NodeKind::Layer { printable, .. } = &mut l.kind {
*printable = def.plot;
}
}
if let Some(c) = l.children_mut() {
*c = children;
}
Arc::new(l)
})
.collect()
}
/// One item as an object, drawn by `map` (world coordinates to its parent's space).
fn item(&mut self, it: &Item, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
if self.nodes >= MAX_NODES {
self.warn(TOO_MANY);
return None;
}
let mut node = match &it.geom {
Geom::Path { bp, fill, width } => self.path(bp, *fill, *width, &it.props, map, inh)?,
Geom::Text(t) => self.text(t, &it.props, map, inh)?,
Geom::Insert(ins) => return self.insert(ins, &it.props, map, inh, depth),
};
node.opacity = self.opacity(&it.props, inh);
self.nodes += 1;
Some(node)
}
fn path(&mut self, bp: &BezPath, fill: bool, width: f64, p: &Props, map: Affine, inh: &Inherit) -> Option<Node> {
let mut bp = bp.clone();
bp.apply_affine(map);
super::curve::bounds(&bp)?;
let paint = Paint::solid(rgb_color(self.rgb(p, inh)));
let scale = map.determinant().abs().sqrt();
let appearance = if fill {
Appearance::basic(paint, Paint::None, 0.0)
} else {
let lw = self.lineweight(p, inh);
let w = if width > 0.0 {
width * scale
} else if lw == 0 {
THINNEST
} else {
f64::from(lw) / 100.0 * PT_PER_MM * self.lw_scale
};
let mut ap = Appearance::basic(Paint::None, paint, w);
if let Some(AppearanceItem::Stroke(s)) = ap.items.last_mut()
&& let Some((dash, dots)) = self.dash(&self.linetype(p, inh), p, scale)
{
s.dash = Some(dash);
if dots {
s.cap = LineCap::Round;
}
}
ap
};
let rule = if fill { FillRule::EvenOdd } else { FillRule::NonZero };
let path = PathData::from_bezpath(&bp);
let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false };
let mut n = if path.subpaths.len() > 1 {
// Several loops (a hatch with islands) paint as one compound path.
let children = path
.subpaths
.into_iter()
.map(|sp| {
let mut c = Node::new(self.doc.alloc_id(), kind(PathData::single(sp)));
c.appearance = appearance.clone();
Arc::new(c)
})
.collect();
Node::new(self.doc.alloc_id(), NodeKind::Compound { children, rule })
} else {
Node::new(self.doc.alloc_id(), kind(path))
};
n.appearance = appearance;
Some(n)
}
fn text(&mut self, t: &TextItem, p: &Props, map: Affine, inh: &Inherit) -> Option<Node> {
// Text space is y-down points; the item's is y-up drawing units.
let m = map * t.xf * Affine::FLIP_Y;
let s = m.determinant().abs().sqrt();
if !(s > 1e-12 && s.is_finite()) {
return None;
}
let family = t.family.clone().or_else(|| self.d.styles.get(&t.style).filter(|f| !f.is_empty()).cloned());
let default = CharStyle::default();
let style = CharStyle {
font_family: family.unwrap_or(default.font_family.clone()),
size: t.height / CAP_HEIGHT * s,
h_scale: t.width * 100.0,
leading: t.leading.map(|l| l * s),
fill: Paint::solid(rgb_color(self.rgb(p, inh))),
..default
};
let mut obj = TextObject::point(Point::ZERO, &t.text, style);
obj.xf = m * Affine::scale(1.0 / s);
obj.para.justify = t.justify;
if let Some(span) = t.span {
fill_span(&mut obj, span, s);
}
Some(Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))))
}
/// An insert: a symbol instance per copy (anonymous blocks: a group of their art), grouped
/// with its attributes.
fn insert(&mut self, ins: &InsertItem, p: &Props, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
let child = self.inherit(p, inh);
let base = self.d.blocks.get(&ins.block).map_or(Point::ZERO, |b| b.base);
let unbase = Affine::translate(-base.to_vec2());
let anonymous = ins.block.starts_with('*');
let symbol = if anonymous { None } else { self.symbol(&ins.block, &child, depth) };
let mut parts = vec![];
for cell in &ins.cells {
let node = match &symbol {
Some(name) => {
let xf = map * *cell * self.symbol_map.inverse();
self.nodes += 1;
Some(Node::new(self.doc.alloc_id(), NodeKind::SymbolInstance { symbol: name.clone(), xf }))
}
None if anonymous => self.art(&ins.block, map * *cell * unbase, &child, depth),
None => None,
};
parts.extend(node);
}
for a in &ins.attribs {
parts.extend(self.item(a, map, &child, depth));
}
let mut node = match parts.len() {
0 => return None,
1 => parts.pop()?,
_ => Node::group(self.doc.alloc_id(), parts.into_iter().map(Arc::new).collect()),
};
node.opacity = self.opacity(p, inh);
Some(node)
}
/// The art of block `name` drawn by `map`, as one object.
fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
let items = self.block(name)?;
if !self.enter(name, depth) {
return None;
}
let mut nodes = vec![];
for it in items.iter() {
// Art on a hidden layer stays hidden inside blocks.
if self.d.layer(self.layer_name(&it.props, inh)).is_some_and(|l| l.hidden) && it.props.layer != "0" {
continue;
}
nodes.extend(self.item(it, map, inh, depth + 1));
}
self.stack.pop();
match nodes.len() {
0 => None,
1 => nodes.pop(),
_ => Some(Node::group(self.doc.alloc_id(), nodes.into_iter().map(Arc::new).collect())),
}
}
/// The symbol for block `name` placed with `inh` (made once per look).
fn symbol(&mut self, name: &str, inh: &Inherit, depth: u32) -> Option<String> {
let inh = if self.is_sensitive(name, depth) { inh.clone() } else { Inherit::default() };
let key = (name.to_string(), inh);
if let Some(s) = self.symbols.get(&key) {
return s.clone();
}
let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base);
let map = self.symbol_map * Affine::translate(-base.to_vec2());
let art = self.art(name, map, &key.1, depth);
let made = art.map(|art| {
let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str());
let symbol = unique(label, |n| self.doc.symbols.iter().any(|s| s.name == n));
self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) });
symbol
});
self.symbols.insert(key, made.clone());
made
}
}
/// Fit text stretched or squeezed across, aligned text scaled as a whole, to run its span
/// (`s`: points per drawing unit), measured in the font it is set in.
fn fill_span(obj: &mut TextObject, span: Span, s: f64) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj);
let natural = layout.lines.iter().map(|l| l.x1 - l.x0).fold(0.0, f64::max);
let Some(k) = span.scale(natural / s) else { return };
for r in &mut obj.runs {
match span {
Span::Fit(_) => r.style.h_scale *= k,
Span::Aligned(_) => r.style.size *= k,
}
}
}
fn rgb_color([r, g, b]: [u8; 3]) -> Color {
Color::rgb8(r, g, b)
}
/// `base`, or `base 2`, `base 3`… when taken.
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
+381
View File
@@ -0,0 +1,381 @@
//! CAD curves as Bézier paths: polylines with bulges (arc segments), circular and elliptical
//! arcs, and splines (NURBS of any degree, rational or not, or a curve through fit points).
//!
//! Curves are built in drawing units with tolerances relative to their own size, so they keep
//! their accuracy at any scale the drawing is imported at.
use kurbo::{Arc, BezPath, Shape, Vec2};
use vectorcraft_geom::{Affine, Point};
/// Arcs are approximated to within this fraction of their radius.
const ARC_TOLERANCE: f64 = 1e-6;
/// Splines are approximated to within this fraction of their control polygon's size.
const SPLINE_TOLERANCE: f64 = 1e-6;
/// The most Bézier pieces one spline span may become.
const MAX_DEPTH: u32 = 12;
/// The most path elements one spline may become (beyond, its pieces aren't split further).
const MAX_ELEMENTS: usize = 200_000;
/// The highest spline degree read.
const MAX_DEGREE: usize = 11;
/// A polyline vertex: its point and the bulge of the segment that starts there (the tangent of a
/// quarter of the arc's angle; positive turns counter-clockwise).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Vertex {
pub p: Point,
pub bulge: f64,
}
/// A polyline: straight segments, and arcs where a vertex has a bulge.
pub(crate) fn polyline(verts: &[Vertex], closed: bool) -> Option<BezPath> {
let first = verts.first()?;
let mut bp = BezPath::new();
bp.move_to(first.p);
let n = verts.len();
let segs = if closed { n } else { n - 1 };
for i in 0..segs {
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { break };
bulge_to(&mut bp, a.p, b.p, a.bulge);
}
if closed {
bp.close_path();
}
Some(bp)
}
/// A segment from `a` (the current point) to `b`, an arc when `bulge` isn't 0.
fn bulge_to(bp: &mut BezPath, a: Point, b: Point, bulge: f64) {
let chord = b - a;
let len = chord.hypot();
if bulge.abs() < 1e-12 || len < 1e-12 || !bulge.is_finite() {
bp.line_to(b);
return;
}
// The included angle is 4·atan(bulge); the centre lies off the chord's middle.
let sweep = 4.0 * bulge.atan();
let radius = len / (2.0 * (sweep / 2.0).sin()).abs();
let normal = Vec2::new(-chord.y, chord.x) / len;
let offset = (radius * radius - len * len / 4.0).max(0.0).sqrt();
// A counter-clockwise arc under a half turn has its centre left of the chord.
let side = if (bulge > 0.0) == (bulge.abs() < 1.0) { 1.0 } else { -1.0 };
let centre = a.midpoint(b) + normal * offset * side;
let start = (a - centre).atan2();
arc_to(bp, centre, radius, start, sweep);
}
/// An arc of a circle from angle `start` (radians) through `sweep` (positive: counter-clockwise),
/// from the current point.
fn arc_to(bp: &mut BezPath, centre: Point, radius: f64, start: f64, sweep: f64) {
let arc = Arc::new(centre, (radius, radius), start, sweep, 0.0);
for el in arc.append_iter((radius * ARC_TOLERANCE).max(1e-12)) {
bp.push(el);
}
}
/// A circular arc from `start` through `sweep` radians as its own subpath; a whole circle closes.
pub(crate) fn arc(centre: Point, radius: f64, start: f64, sweep: f64) -> Option<BezPath> {
if !(radius > 0.0 && radius.is_finite() && sweep.is_finite()) {
return None;
}
let full = sweep.abs() >= std::f64::consts::TAU - 1e-9;
let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU);
let mut bp = BezPath::new();
bp.move_to(centre + Vec2::from_angle(start) * radius);
arc_to(&mut bp, centre, radius, start, sweep);
if full {
bp.close_path();
}
Some(bp)
}
/// An elliptical arc: the unit circle's arc from `start` through `sweep` radians mapped by `frame`
/// (its columns are the major and minor semi-axes, its translation the centre).
pub(crate) fn ellipse(frame: Affine, start: f64, sweep: f64) -> Option<BezPath> {
let [a, b, c, d, _, _] = frame.as_coeffs();
let size = a.hypot(b).max(c.hypot(d));
if !(size > 1e-12 && size.is_finite()) {
return None;
}
let full = sweep.abs() >= std::f64::consts::TAU - 1e-9;
let sweep = sweep.clamp(-std::f64::consts::TAU, std::f64::consts::TAU);
let unit = Arc::new(Point::ZERO, (1.0, 1.0), start, sweep, 0.0);
let mut bp = BezPath::new();
bp.move_to(Point::ZERO + Vec2::from_angle(start));
for el in unit.append_iter(ARC_TOLERANCE) {
bp.push(el);
}
if full {
bp.close_path();
}
bp.apply_affine(frame);
Some(bp)
}
/// A point with a weight, in homogeneous form (`x·w, y·w, w`).
#[derive(Clone, Copy, Debug)]
struct H {
x: f64,
y: f64,
w: f64,
}
impl H {
fn lerp(self, o: Self, t: f64) -> Self {
Self { x: self.x + (o.x - self.x) * t, y: self.y + (o.y - self.y) * t, w: self.w + (o.w - self.w) * t }
}
fn point(self) -> Option<Point> {
(self.w.abs() > 1e-300).then(|| Point::new(self.x / self.w, self.y / self.w)).filter(|p| p.x.is_finite() && p.y.is_finite())
}
}
/// A NURBS curve of `degree` with `knots`, control points `ctrl` and their `weights` (none:
/// all 1) as Bézier pieces: exact for polynomial curves up to degree 3, else within
/// [`SPLINE_TOLERANCE`] of the control polygon's size.
pub(crate) fn nurbs(degree: usize, knots: &[f64], ctrl: &[Point], weights: Option<&[f64]>) -> Option<BezPath> {
let n = ctrl.len();
if !(1..=MAX_DEGREE).contains(&degree) || n <= degree || knots.len() != n + degree + 1 {
return None;
}
if knots.windows(2).any(|w| w[1] < w[0]) {
return None;
}
let weight = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0);
let hs: Vec<H> = ctrl.iter().enumerate().map(|(i, p)| H { x: p.x * weight(i), y: p.y * weight(i), w: weight(i) }).collect();
let rational = (0..n).any(|i| (weight(i) - weight(0)).abs() > 1e-12);
let (lo, hi) = ctrl.iter().fold((Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)), |(lo, hi), p| {
(Point::new(lo.x.min(p.x), lo.y.min(p.y)), Point::new(hi.x.max(p.x), hi.y.max(p.y)))
});
let tol = ((hi - lo).hypot() * SPLINE_TOLERANCE).max(1e-12);
let mut bp = BezPath::new();
// Each knot span of the domain is one polynomial piece: its Bézier points are blossoms.
for span in degree..n {
let (Some(&t0), Some(&t1)) = (knots.get(span), knots.get(span + 1)) else { continue };
if t1 - t0 <= 1e-12 * (1.0 + t0.abs()) {
continue;
}
let bez: Vec<H> = (0..=degree)
.map(|j| {
let args: Vec<f64> = (0..degree).map(|k| if k < degree - j { t0 } else { t1 }).collect();
blossom(degree, knots, &hs, span, &args)
})
.collect::<Option<_>>()?;
let start = bez.first().and_then(|h| h.point())?;
if bp.elements().is_empty() {
bp.move_to(start);
}
let pts: Option<Vec<Point>> = if rational { None } else { bez.iter().map(|h| h.point()).collect() };
match pts.as_deref() {
Some([_, p]) => bp.line_to(*p),
Some([_, q, p]) => bp.quad_to(*q, *p),
Some([_, a, b, p]) => bp.curve_to(*a, *b, *p),
_ => fit(&mut bp, &bez, 0.0, 1.0, tol, 0)?,
}
}
(bp.elements().len() > 1).then_some(bp)
}
/// The blossom of span `span` at `args` (de Boor's algorithm with one parameter per level).
fn blossom(degree: usize, knots: &[f64], ctrl: &[H], span: usize, args: &[f64]) -> Option<H> {
let mut d: Vec<H> = ctrl.get(span - degree..=span)?.to_vec();
for r in 1..=degree {
let u = *args.get(r - 1)?;
for j in (r..=degree).rev() {
let i = span - degree + j;
let (a, b) = (*knots.get(i)?, *knots.get(i + degree + 1 - r)?);
let alpha = if b - a > 1e-300 { (u - a) / (b - a) } else { 0.0 };
let prev = *d.get(j - 1)?;
let cur = d.get_mut(j)?;
*cur = prev.lerp(*cur, alpha);
}
}
d.get(degree).copied()
}
/// A (rational) Bézier of any degree at `t`, with its derivative.
fn eval(bez: &[H], t: f64) -> Option<(Point, Vec2)> {
let mut pts = bez.to_vec();
let n = pts.len().checked_sub(1)?;
// Down to the two points whose difference gives the tangent.
for level in (1..=n).rev() {
if level == 1 {
let (a, b) = (*pts.first()?, *pts.get(1)?);
let p = a.lerp(b, t);
let pt = p.point()?;
// d/dt (X/W) = (X'·W − X·W') / W², with X' = n·(b − a).
let k = n as f64;
let (dx, dy, dw) = (k * (b.x - a.x), k * (b.y - a.y), k * (b.w - a.w));
let w2 = p.w * p.w;
let tangent = Vec2::new((dx * p.w - p.x * dw) / w2, (dy * p.w - p.y * dw) / w2);
return Some((pt, tangent));
}
for i in 0..level {
let next = *pts.get(i + 1)?;
let cur = pts.get_mut(i)?;
*cur = cur.lerp(next, t);
}
}
// Degree 0: a point.
Some((pts.first()?.point()?, Vec2::ZERO))
}
/// Cubic pieces through the curve `bez` from `t0` to `t1`, each matching its ends and their
/// tangents, split until within `tol` of the curve.
fn fit(bp: &mut BezPath, bez: &[H], t0: f64, t1: f64, tol: f64, depth: u32) -> Option<()> {
let (p0, d0) = eval(bez, t0)?;
let (p3, d3) = eval(bez, t1)?;
let h = (t1 - t0) / 3.0;
let (c1, c2) = (p0 + d0 * h, p3 - d3 * h);
let cubic = kurbo::CubicBez::new(p0, c1, c2, p3);
let close = (1..8).all(|i| {
let s = f64::from(i) / 8.0;
eval(bez, t0 + (t1 - t0) * s).is_some_and(|(p, _)| kurbo::ParamCurve::eval(&cubic, s).distance(p) <= tol)
});
if close || depth >= MAX_DEPTH || bp.elements().len() >= MAX_ELEMENTS {
if [c1, c2].iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
bp.curve_to(c1, c2, p3);
} else {
bp.line_to(p3);
}
return Some(());
}
let mid = (t0 + t1) / 2.0;
fit(bp, bez, t0, mid, tol, depth + 1)?;
fit(bp, bez, mid, t1, tol, depth + 1)
}
/// A smooth curve through `pts` (a spline given by fit points only): Catmull-Rom tangents.
pub(crate) fn through(pts: &[Point], closed: bool) -> Option<BezPath> {
let n = pts.len();
let first = *pts.first()?;
if n < 2 {
return None;
}
let at = |i: isize| -> Point {
let len = n as isize;
let j = if closed { i.rem_euclid(len) } else { i.clamp(0, len - 1) };
pts.get(j as usize).copied().unwrap_or(first)
};
let mut bp = BezPath::new();
bp.move_to(first);
let segs = if closed { n } else { n - 1 };
for i in 0..segs as isize {
let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));
bp.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2);
}
if closed {
bp.close_path();
}
Some(bp)
}
/// `bp`'s bounds, when it has any finite extent.
pub(crate) fn bounds(bp: &BezPath) -> Option<vectorcraft_geom::Rect> {
if bp.elements().is_empty() {
return None;
}
let r = bp.bounding_box();
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r)
}
#[cfg(test)]
mod tests {
use kurbo::PathEl;
use super::*;
/// The largest distance from `centre` minus `r` over points along `bp`.
fn radial_error(bp: &BezPath, centre: Point, r: f64) -> f64 {
let mut worst: f64 = 0.0;
for seg in bp.segments() {
for i in 0..=32 {
let p = kurbo::ParamCurve::eval(&seg, f64::from(i) / 32.0);
worst = worst.max((p.distance(centre) - r).abs());
}
}
worst
}
#[test]
fn a_bulge_of_one_is_a_half_circle_on_the_right_of_its_chord() {
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: 1.0 }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
let bp = polyline(&verts, false).unwrap();
assert!(radial_error(&bp, Point::new(5.0, 0.0), 5.0) < 1e-4);
// Counter-clockwise from (0, 0) to (10, 0) passes below the chord.
let b = bp.bounding_box();
assert!((b.y0 + 5.0).abs() < 1e-3 && b.y1.abs() < 1e-9, "{b:?}");
// A negative bulge turns the other way; a small one is a shallow arc.
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: -0.25 }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
let bp = polyline(&verts, false).unwrap();
// Sagitta = bulge · chord / 2; radius = chord (1 + bulge²) / (4 |bulge|).
let b = bp.bounding_box();
assert!((b.y1 - 1.25).abs() < 1e-4 && b.y0.abs() < 1e-9, "{b:?}");
let r = 10.0 * (1.0 + 0.0625) / 1.0;
assert!(radial_error(&bp, Point::new(5.0, 1.25 - r), r) < 1e-4);
}
#[test]
fn a_bulge_over_one_is_the_major_arc() {
let b = (3.0 * std::f64::consts::PI / 8.0).tan(); // a 270° arc
let verts = [Vertex { p: Point::new(0.0, 0.0), bulge: b }, Vertex { p: Point::new(10.0, 0.0), bulge: 0.0 }];
let bp = polyline(&verts, false).unwrap();
let r = 10.0 / (2.0 * (135f64).to_radians().sin());
// Counter-clockwise, so the centre and most of the circle lie right of the chord.
let centre = Point::new(5.0, -5.0);
assert!(radial_error(&bp, centre, r) < 1e-4);
assert!(bp.bounding_box().y0 < -12.0, "most of the circle lies below the chord");
}
#[test]
fn a_rational_quadratic_circle_stays_on_the_circle() {
// A whole circle of radius 100 as nine weighted control points (four quarter arcs).
let w = std::f64::consts::FRAC_1_SQRT_2;
let ctrl = [(1., 0.), (1., 1.), (0., 1.), (-1., 1.), (-1., 0.), (-1., -1.), (0., -1.), (1., -1.), (1., 0.)]
.map(|(x, y)| Point::new(x * 100.0, y * 100.0));
let weights = [1.0, w, 1.0, w, 1.0, w, 1.0, w, 1.0];
let knots = [0.0, 0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0, 4.0];
let bp = nurbs(2, &knots, &ctrl, Some(&weights)).unwrap();
let err = radial_error(&bp, Point::ZERO, 100.0);
assert!(err < 0.01, "error {err}");
}
#[test]
fn polynomial_splines_become_exact_beziers() {
// A clamped cubic with one interior knot: two cubic pieces meeting at its blossom.
let ctrl = [(0., 0.), (10., 20.), (30., 20.), (40., 0.), (50., -10.)].map(|(x, y)| Point::new(x, y));
let knots = [0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0];
let bp = nurbs(3, &knots, &ctrl, None).unwrap();
assert_eq!(bp.elements().iter().filter(|e| matches!(e, PathEl::CurveTo(..))).count(), 2);
assert!(matches!(bp.elements().first(), Some(PathEl::MoveTo(p)) if *p == Point::ZERO));
let end = match bp.elements().last() {
Some(PathEl::CurveTo(_, _, p)) => *p,
other => panic!("{other:?}"),
};
assert_eq!(end, Point::new(50.0, -10.0));
// Bad knot vectors are refused rather than read.
assert!(nurbs(3, &knots[1..], &ctrl, None).is_none());
assert!(nurbs(3, &[0.0, 0.0, 0.0, 0.0, 2.0, 1.0, 2.0, 2.0, 2.0], &ctrl, None).is_none());
assert!(nurbs(0, &knots, &ctrl, None).is_none());
}
#[test]
fn a_high_degree_spline_is_fitted_within_tolerance() {
// Degree 5 Bézier (one span) of a known polynomial: compare with direct evaluation.
let ctrl: Vec<Point> = (0..6).map(|i| Point::new(f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 30.0 })).collect();
let knots = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
let bp = nurbs(5, &knots, &ctrl, None).unwrap();
let hs: Vec<H> = ctrl.iter().map(|p| H { x: p.x, y: p.y, w: 1.0 }).collect();
for i in 0..=20 {
let (p, _) = eval(&hs, f64::from(i) / 20.0).unwrap();
let near = bp.segments().map(|s| kurbo::ParamCurveNearest::nearest(&s, p, 1e-9).distance_sq.sqrt()).fold(f64::MAX, f64::min);
assert!(near < 0.01, "error {near}");
}
}
#[test]
fn fit_points_make_a_smooth_curve_through_them() {
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)];
let bp = through(&pts, false).unwrap();
let ends: Vec<Point> = bp.elements().iter().filter_map(|e| if let PathEl::CurveTo(_, _, p) = e { Some(*p) } else { None }).collect();
assert_eq!(ends, [Point::new(10.0, 10.0), Point::new(20.0, 0.0)]);
}
}
+704
View File
@@ -0,0 +1,704 @@
//! Entities to drawing items: their geometry in world coordinates (drawing units, y up) and the
//! properties they take from their layer or block (resolved when the items become art).
use std::collections::BTreeMap;
use kurbo::{BezPath, PathEl};
use vectorcraft_doc::Justify;
use vectorcraft_geom::{Affine, Point, Vec2};
use super::curve::{self, Vertex};
use super::reader::{Groups, Pair, Record, groups, number};
use crate::aci::aci_rgb;
/// How far below the baseline the lowest descender reaches, as a fraction of the cap height.
const DESCENT: f64 = 0.3;
/// Baseline-to-baseline distance of multiline text at line spacing 1, in cap heights.
const LINE_SPACING: f64 = 5.0 / 3.0;
/// The most copies one array insert (MINSERT) makes.
const MAX_CELLS: i64 = 10_000;
/// Art farther than this from the origin (drawing units) is damaged data, left out.
const MAX_COORD: f64 = 1e12;
/// How far fit and aligned text may be squeezed or stretched to span their two points: at width
/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows.
const SPAN_SCALE: (f64, f64) = (0.01, 100.0);
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
pub(crate) fn aci_paper(i: u64) -> [u8; 3] {
match u8::try_from(i) {
Ok(7) | Err(_) => [0; 3],
Ok(i) => aci_rgb(i),
}
}
/// The true colour (group 420, `0xRRGGBB`) of an entity or layer.
pub(crate) fn true_color(g: &[Pair<'_>]) -> Option<[u8; 3]> {
let v = g.int(420)?;
let [_, r, gr, b] = u32::try_from(v & 0xFF_FFFF).ok()?.to_be_bytes();
Some([r, gr, b])
}
/// An entity's colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Col {
ByLayer,
ByBlock,
Rgb([u8; 3]),
}
/// An entity's lineweight.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Lw {
ByLayer,
ByBlock,
Default,
/// Hundredths of a millimetre.
Hundredths(i32),
}
/// An entity's opacity.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Alpha {
ByLayer,
ByBlock,
Opacity(f32),
}
/// What an entity takes from its layer or block unless it sets it itself.
#[derive(Clone, Debug)]
pub(crate) struct Props {
pub layer: String,
pub color: Col,
pub lineweight: Lw,
/// The linetype name in upper case (`BYLAYER`, `BYBLOCK`, `CONTINUOUS` or a table entry).
pub linetype: String,
pub ltscale: f64,
pub alpha: Alpha,
}
impl Props {
fn read(g: &[Pair<'_>]) -> Self {
let color = match (true_color(g), g.int(62)) {
(Some(rgb), _) => Col::Rgb(rgb),
(None, Some(0)) => Col::ByBlock,
(None, None | Some(256..)) => Col::ByLayer,
(None, Some(i)) => Col::Rgb(aci_paper(i.unsigned_abs())),
};
let lineweight = match g.int(370) {
Some(-2) => Lw::ByBlock,
Some(-3) => Lw::Default,
Some(v @ 0..=211) => Lw::Hundredths(v as i32),
_ => Lw::ByLayer,
};
let alpha = match g.int(440) {
Some(v) if v & 0x0200_0000 != 0 => Alpha::Opacity((v & 0xFF) as f32 / 255.0),
Some(v) if v & 0x0100_0000 != 0 => Alpha::ByBlock,
_ => Alpha::ByLayer,
};
Self {
layer: g.name(8).unwrap_or("0").to_string(),
color,
lineweight,
linetype: g.name(6).unwrap_or("BYLAYER").to_uppercase(),
ltscale: g.num(48).filter(|v| *v > 0.0).unwrap_or(1.0),
alpha,
}
}
}
/// A line of text.
#[derive(Clone, Debug)]
pub(crate) struct TextItem {
pub text: String,
/// Cap height, in drawing units.
pub height: f64,
/// Text space (the first baseline's start at the origin, x along it, y up; drawing units) to
/// world coordinates.
pub xf: Affine,
/// Width factor (1: as designed).
pub width: f64,
pub justify: Justify,
/// The text style (upper case).
pub style: String,
/// A font family the text names itself (multiline text's font changes).
pub family: Option<String>,
/// Baseline-to-baseline distance (drawing units), for several lines.
pub leading: Option<f64>,
/// The distance aligned and fit text run along their baseline.
pub span: Option<Span>,
}
/// How text fills the distance between its two points (drawing units).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Span {
/// Stretched or squeezed across, its height kept.
Fit(f64),
/// Scaled as a whole, height included.
Aligned(f64),
}
impl Span {
/// The scale that makes text `natural` long run the span, within [`SPAN_SCALE`] (None when
/// either length is degenerate).
pub fn scale(self, natural: f64) -> Option<f64> {
let (Self::Fit(d) | Self::Aligned(d)) = self;
let k = d / natural;
(k.is_finite() && k > 0.0).then(|| k.clamp(SPAN_SCALE.0, SPAN_SCALE.1))
}
}
/// A block reference: one transform per copy (an array insert makes several), each from the
/// block's coordinates (less its base point) to world coordinates.
#[derive(Clone, Debug)]
pub(crate) struct InsertItem {
/// The block (upper case).
pub block: String,
pub cells: Vec<Affine>,
/// Its attributes' text.
pub attribs: Vec<Item>,
}
#[derive(Clone, Debug)]
pub(crate) enum Geom {
/// A path in world coordinates; `fill`: a filled area (hatch, solid), else a line of
/// `width` drawing units (0: the lineweight).
Path {
bp: BezPath,
fill: bool,
width: f64,
},
Text(TextItem),
Insert(InsertItem),
}
/// An entity as art: its geometry and its properties.
#[derive(Clone, Debug)]
pub(crate) struct Item {
pub props: Props,
pub geom: Geom,
}
/// Turns entity records into items, noting what it leaves out.
#[derive(Default)]
pub(crate) struct Converter {
/// Entity types left out, with how many.
pub skipped: BTreeMap<String, usize>,
pub warnings: Vec<String>,
}
const PATTERN_HATCH: &str = "pattern hatches come in as their boundaries";
const GRADIENT_HATCH: &str = "gradient hatches are filled with their first colour";
const MESHES: &str = "3D polygon meshes and polyface meshes are left out";
const BIG_ARRAY: &str = "array inserts are limited to 10,000 copies each";
const FAR_AWAY: &str = "entities with coordinates out of range are left out";
impl Converter {
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
fn skip(&mut self, kind: &str) {
*self.skipped.entry(kind.to_string()).or_default() += 1;
}
/// The items of `records` (an entity section or a block), in drawing order.
pub fn items(&mut self, records: &[Record<'_>]) -> Vec<Item> {
let mut out = Vec::new();
for group in groups(records) {
let Some(head) = group.first() else { continue };
// Invisible entities.
if head.g.int(60) == Some(1) {
continue;
}
match self.geom(head, group) {
Some(geom) if !in_range(&geom) => self.warn(FAR_AWAY),
Some(geom) => out.push(Item { props: Props::read(head.g), geom }),
None if is_known(head.kind) => {}
None => self.skip(head.kind),
}
}
out
}
fn geom(&mut self, head: &Record<'_>, group: &[Record<'_>]) -> Option<Geom> {
let g = head.g;
let path = |bp: BezPath| Some(Geom::Path { bp, fill: false, width: 0.0 });
match head.kind {
"LINE" => {
let mut bp = BezPath::new();
bp.move_to(g.point(10));
bp.line_to(g.point(11));
path(bp)
}
"CIRCLE" => path(transformed(curve::arc(g.point(10), g.num(40)?, 0.0, std::f64::consts::TAU)?, ocs(g))),
"ARC" => {
let (start, end) = (g.num_or(50, 0.0), g.num_or(51, 360.0));
let sweep = ccw_sweep(start, end);
path(transformed(curve::arc(g.point(10), g.num(40)?, start.to_radians(), sweep.to_radians())?, ocs(g)))
}
"ELLIPSE" => path(ellipse(g)?),
"LWPOLYLINE" => lwpolyline(g),
"POLYLINE" => self.polyline(g, group.get(1..).unwrap_or(&[])),
"SPLINE" => path(spline(g)?),
"SOLID" | "TRACE" => {
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
// The corners zigzag: the outline runs 1, 2, 4, 3.
Some(Geom::Path { bp: transformed(polygon(&[a, b, d, c]), ocs(g)), fill: true, width: 0.0 })
}
"3DFACE" => {
let [a, b, c, d] = [g.point(10), g.point(11), g.point(12), g.point_opt(13).unwrap_or(g.point(12))];
path(polygon(&[a, b, c, d]))
}
"HATCH" => self.hatch(g),
"TEXT" | "ATTRIB" => text(g, head.kind == "ATTRIB").map(Geom::Text),
"MTEXT" => mtext(g).map(Geom::Text),
"INSERT" => self.insert(g, group.get(1..).unwrap_or(&[])),
// A dimension draws the anonymous block of its lines, arrows and text.
"DIMENSION" => Some(Geom::Insert(InsertItem { block: g.name(2)?.to_uppercase(), cells: vec![Affine::IDENTITY], attribs: vec![] })),
_ => None,
}
}
fn polyline(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
let flags = g.int_or(70, 0);
if flags & (16 | 64) != 0 {
self.warn(MESHES);
return None;
}
// Spline frame control points and polyface faces aren't on the line.
let verts: Vec<Vertex> = rest
.iter()
.filter(|r| r.kind == "VERTEX" && r.g.int_or(70, 0) & (16 | 128) == 0)
.map(|r| Vertex { p: r.g.point(10), bulge: r.g.num_or(42, 0.0) })
.collect();
let bp = curve::polyline(&verts, flags & 1 != 0)?;
// 3D polylines are in world coordinates; 2D ones in their plane's.
let bp = if flags & 8 != 0 { bp } else { transformed(bp, ocs(g)) };
Some(Geom::Path { bp, fill: false, width: g.num_or(40, 0.0).max(0.0) })
}
fn insert(&mut self, g: &[Pair<'_>], rest: &[Record<'_>]) -> Option<Geom> {
let block = g.name(2)?.to_uppercase();
let (sx, sy) = (g.num_or(41, 1.0), g.num_or(42, 1.0));
if sx.abs() < 1e-12 || sy.abs() < 1e-12 {
return None;
}
let place = ocs(g) * Affine::translate(g.point(10).to_vec2()) * Affine::rotate(g.num_or(50, 0.0).to_radians());
let (cols, rows) = (g.int_or(70, 1).max(1), g.int_or(71, 1).max(1));
let (cs, rs) = (g.num_or(44, 0.0), g.num_or(45, 0.0));
let (cols, rows) = if cols.saturating_mul(rows) > MAX_CELLS {
self.warn(BIG_ARRAY);
(cols.min(MAX_CELLS), (MAX_CELLS / cols.min(MAX_CELLS)).max(1).min(rows))
} else {
(cols, rows)
};
let cells = (0..rows)
.flat_map(|r| (0..cols).map(move |c| (r, c)))
.map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy))
.collect();
let attribs = self.items(rest);
Some(Geom::Insert(InsertItem { block, cells, attribs }))
}
fn hatch(&mut self, g: &[Pair<'_>]) -> Option<Geom> {
let start = g.iter().position(|p| p.code == 91)?;
let count = g.get(start).and_then(|p| number(p.value)).filter(|n| *n >= 0.0)? as usize;
let mut c = Cursor { g, i: start + 1 };
let mut bp = BezPath::new();
for _ in 0..count {
let Some(path) = boundary(&mut c) else { break };
bp.extend(path);
}
if bp.elements().is_empty() {
return None;
}
let bp = transformed(bp, ocs(g));
let solid = g.int_or(70, 0) == 1;
if g.int_or(450, 0) == 1 {
self.warn(GRADIENT_HATCH);
} else if !solid {
self.warn(PATTERN_HATCH);
return Some(Geom::Path { bp, fill: false, width: 0.0 });
}
Some(Geom::Path { bp, fill: true, width: 0.0 })
}
}
/// Is the geometry within [`MAX_COORD`] of the origin?
fn in_range(g: &Geom) -> bool {
let near = |p: Point| p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD;
let origin = |m: &Affine| near(m.translation().to_point());
match g {
Geom::Path { bp, width, .. } => {
curve::bounds(bp).is_some_and(|b| near(Point::new(b.x0, b.y0)) && near(Point::new(b.x1, b.y1))) && *width <= MAX_COORD
}
Geom::Text(t) => origin(&t.xf) && t.height <= MAX_COORD,
Geom::Insert(i) => i.cells.iter().all(|m| origin(m) && m.as_coeffs().iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)),
}
}
/// Entity types that are read but draw nothing themselves (or were reported when read).
fn is_known(kind: &str) -> bool {
matches!(
kind,
"LINE"
| "CIRCLE"
| "ARC"
| "ELLIPSE"
| "LWPOLYLINE"
| "POLYLINE"
| "SPLINE"
| "SOLID"
| "TRACE"
| "3DFACE"
| "HATCH"
| "TEXT"
| "ATTRIB"
| "MTEXT"
| "INSERT"
| "DIMENSION"
| "ATTDEF"
| "SEQEND"
| "VERTEX"
)
}
/// The sweep from `start` to `end` degrees counter-clockwise, in (0, 360].
fn ccw_sweep(start: f64, end: f64) -> f64 {
let s = (end - start).rem_euclid(360.0);
if s < 1e-9 { 360.0 } else { s }
}
fn transformed(mut bp: BezPath, m: Affine) -> BezPath {
if m != Affine::IDENTITY {
bp.apply_affine(m);
}
bp
}
fn polygon(pts: &[Point]) -> BezPath {
let mut bp = BezPath::new();
for (i, p) in pts.iter().enumerate() {
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
}
bp.close_path();
bp
}
/// The extrusion direction (210, 220, 230) of a planar entity, normalised.
fn extrusion(g: &[Pair<'_>]) -> [f64; 3] {
let n = [g.num_or(210, 0.0), g.num_or(220, 0.0), g.num_or(230, 1.0)];
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
if len > 1e-12 && len.is_finite() { n.map(|v| v / len) } else { [0.0, 0.0, 1.0] }
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn unit(v: [f64; 3]) -> [f64; 3] {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len > 1e-12 { v.map(|x| x / len) } else { v }
}
/// A planar entity's coordinate system seen from above: its x and y axes in world space by the
/// arbitrary axis algorithm (identity for drawings in the world's plane; extruded along −z it
/// mirrors x).
fn ocs(g: &[Pair<'_>]) -> Affine {
let n = extrusion(g);
if n[2] > 1.0 - 1e-12 {
return Affine::IDENTITY;
}
let ax = unit(if n[0].abs() < 1.0 / 64.0 && n[1].abs() < 1.0 / 64.0 { cross([0.0, 1.0, 0.0], n) } else { cross([0.0, 0.0, 1.0], n) });
let ay = unit(cross(n, ax));
Affine::new([ax[0], ax[1], ay[0], ay[1], 0.0, 0.0])
}
fn ellipse(g: &[Pair<'_>]) -> Option<BezPath> {
let centre = g.point(10);
let major = g.point(11).to_vec2();
let ratio = g.num_or(40, 1.0);
if ratio <= 0.0 || major.hypot() < 1e-12 {
return None;
}
// The minor axis is the major one turned a quarter about the extrusion direction.
let n = extrusion(g);
let m = cross(n, [major.x, major.y, 0.0]);
let minor = Vec2::new(m[0], m[1]) * ratio;
let (start, end) = (g.num_or(41, 0.0), g.num_or(42, std::f64::consts::TAU));
let sweep = ccw_sweep(start.to_degrees(), end.to_degrees()).to_radians();
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start, sweep)
}
fn lwpolyline(g: &[Pair<'_>]) -> Option<Geom> {
let mut verts: Vec<Vertex> = Vec::new();
let (mut widths, mut uniform) = (None, true);
for p in g {
match p.code {
10 => verts.push(Vertex { p: Point::new(number(p.value)?, 0.0), bulge: 0.0 }),
20 => verts.last_mut()?.p.y = number(p.value)?,
42 => verts.last_mut()?.bulge = number(p.value).unwrap_or(0.0),
40 | 41 => {
let w = number(p.value).unwrap_or(0.0);
uniform &= widths.is_none_or(|x: f64| (x - w).abs() < 1e-12);
widths = Some(w);
}
_ => {}
}
}
let bp = transformed(curve::polyline(&verts, g.int_or(70, 0) & 1 != 0)?, ocs(g));
let width = g.num(43).filter(|w| *w > 0.0).or(widths.filter(|_| uniform)).unwrap_or(0.0).max(0.0);
Some(Geom::Path { bp, fill: false, width })
}
/// The points at `code` (x) and `code + 10` (y), in order.
fn points(g: &[Pair<'_>], code: i32) -> Option<Vec<Point>> {
let mut out: Vec<Point> = Vec::new();
for p in g {
if p.code == code {
out.push(Point::new(number(p.value)?, 0.0));
} else if p.code == code + 10 {
out.last_mut()?.y = number(p.value)?;
}
}
Some(out)
}
fn spline(g: &[Pair<'_>]) -> Option<BezPath> {
let flags = g.int_or(70, 0);
let ctrl = points(g, 10)?;
let degree = usize::try_from(g.int_or(71, 3)).ok()?;
let knots: Vec<f64> = g.iter().filter(|p| p.code == 40).map(|p| number(p.value)).collect::<Option<_>>()?;
let weights: Vec<f64> = g.iter().filter(|p| p.code == 41).filter_map(|p| number(p.value)).collect();
let weights = (flags & 4 != 0 && weights.len() == ctrl.len()).then_some(weights.as_slice());
curve::nurbs(degree, &knots, &ctrl, weights).or_else(|| curve::through(&points(g, 11)?, flags & 1 != 0))
}
/// Reads a hatch's boundary groups in order.
struct Cursor<'g, 'a> {
g: &'g [Pair<'a>],
i: usize,
}
impl Cursor<'_, '_> {
/// The next group's value when it has `code` (consumed).
fn opt(&mut self, code: i32) -> Option<&str> {
let p = self.g.get(self.i).filter(|p| p.code == code)?;
self.i += 1;
Some(p.value)
}
fn num(&mut self, code: i32) -> Option<f64> {
self.opt(code).and_then(number)
}
fn count(&mut self, code: i32) -> Option<usize> {
self.num(code).filter(|n| *n >= 0.0 && *n <= 1e7).map(|n| n as usize)
}
fn point(&mut self, code: i32) -> Option<Point> {
Some(Point::new(self.num(code)?, self.num(code + 10)?))
}
}
/// One hatch boundary loop, closed.
fn boundary(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
let flags = c.num(92)? as i64;
let mut bp = BezPath::new();
if flags & 2 != 0 {
let bulges = c.num(72).unwrap_or(0.0) != 0.0;
let _closed = c.opt(73);
let n = c.count(93)?;
let mut verts = Vec::with_capacity(n.min(4096));
for _ in 0..n {
let p = c.point(10)?;
let bulge = if bulges { c.num(42).unwrap_or(0.0) } else { 0.0 };
verts.push(Vertex { p, bulge });
}
bp = curve::polyline(&verts, true)?;
} else {
let n = c.count(93)?;
for _ in 0..n {
let edge = edge(c)?;
append(&mut bp, &edge);
}
if bp.elements().is_empty() {
return None;
}
bp.close_path();
}
// The boundary objects it was made from.
if let Some(n) = c.count(97) {
for _ in 0..n {
c.opt(330);
}
}
Some(bp)
}
/// One edge of a hatch boundary.
fn edge(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
match c.num(72)? as i64 {
1 => {
let mut bp = BezPath::new();
bp.move_to(c.point(10)?);
bp.line_to(c.point(11)?);
Some(bp)
}
2 => {
let (centre, r, start, end) = (c.point(10)?, c.num(40)?, c.num(50)?, c.num(51)?);
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
let sweep = ccw_sweep(start, end);
// Clockwise arcs have their angles measured clockwise.
let (start, sweep) = if ccw { (start, sweep) } else { (-start, -sweep) };
curve::arc(centre, r, start.to_radians(), sweep.to_radians())
}
3 => {
let (centre, major, ratio, start, end) = (c.point(10)?, c.point(11)?.to_vec2(), c.num(40)?, c.num(50)?, c.num(51)?);
let ccw = c.num(73).unwrap_or(1.0) != 0.0;
let minor = Vec2::new(-major.y, major.x) * ratio * if ccw { 1.0 } else { -1.0 };
let sweep = ccw_sweep(start, end).to_radians();
curve::ellipse(Affine::new([major.x, major.y, minor.x, minor.y, centre.x, centre.y]), start.to_radians(), sweep)
}
4 => {
let degree = c.count(94)?;
let rational = c.num(73).unwrap_or(0.0) != 0.0;
let _periodic = c.opt(74);
let (nk, nc) = (c.count(95)?, c.count(96)?);
let knots: Vec<f64> = (0..nk).map(|_| c.num(40)).collect::<Option<_>>()?;
let mut ctrl = Vec::with_capacity(nc.min(4096));
let mut weights = Vec::with_capacity(nc.min(4096));
for _ in 0..nc {
ctrl.push(c.point(10)?);
weights.push(c.num(42).unwrap_or(1.0));
}
// Fit data (DXF 2010 and later): a count, then that many points. Older files have none,
// and the 97 that may follow is the boundary's count of source objects (then their
// handles), so it is only the fit count when it is 0 or points follow.
let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) {
(Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11),
_ => false,
};
let fit: Vec<Point> = match fit_count.then(|| c.count(97)).flatten() {
Some(n) => (0..n).map(|_| c.point(11)).collect::<Option<_>>()?,
None => vec![],
};
// Start and end tangents.
for code in [12, 13] {
c.point(code);
}
curve::nurbs(degree, &knots, &ctrl, rational.then_some(weights.as_slice())).or_else(|| curve::through(&fit, false))
}
_ => None,
}
}
/// `seg` continuing `bp`: joined by a line when it starts elsewhere.
fn append(bp: &mut BezPath, seg: &BezPath) {
for el in seg.elements() {
match *el {
PathEl::MoveTo(p) => {
let here = bp.elements().last().and_then(|e| e.end_point());
match here {
None => bp.move_to(p),
Some(q) if q.distance(p) > 1e-9 => bp.line_to(p),
Some(_) => {}
}
}
PathEl::ClosePath => {}
other => bp.push(other),
}
}
}
/// The baseline offset of vertical alignment `v` (0 baseline, 1 bottom, 2 middle, 3 top).
fn baseline(v: i64, height: f64) -> f64 {
match v {
1 => height * DESCENT,
2 => -height / 2.0,
3 => -height,
_ => 0.0,
}
}
fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
if attrib && g.int_or(70, 0) & 1 != 0 {
return None;
}
let s = super::text::plain(g.raw(1)?);
if s.trim().is_empty() {
return None;
}
let height = g.num(40).filter(|h| *h > 0.0)?;
let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0));
let (p1, p2) = (g.point(10), g.point_opt(11));
let mut angle = g.num_or(50, 0.0).to_radians();
let mut span = None;
// Aligned and fit text run from the first point to the second.
let anchor = match (h, p2) {
(3 | 5, Some(p)) => {
angle = (p - p1).atan2();
let d = p1.distance(p);
span = (d > 1e-12).then_some(if h == 3 { Span::Aligned(d) } else { Span::Fit(d) });
p1
}
(0, _) if v == 0 => p1,
(_, Some(p)) => p,
_ => p1,
};
let justify = match h {
1 | 4 => Justify::Center,
2 => Justify::Right,
_ => Justify::Left,
};
let v = if h == 4 { 2 } else { v };
let flags = g.int_or(71, 0);
let mirror = Affine::scale_non_uniform(if flags & 2 != 0 { -1.0 } else { 1.0 }, if flags & 4 != 0 { -1.0 } else { 1.0 });
let xf = ocs(g) * Affine::translate(anchor.to_vec2()) * Affine::rotate(angle) * mirror * Affine::translate((0.0, baseline(v, height)));
Some(TextItem {
text: s,
height,
xf,
width: g.num(41).filter(|w| *w > 0.0).unwrap_or(1.0),
justify,
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
family: None,
leading: None,
span,
})
}
fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
// The text comes in 250-character chunks (3) before the last (1).
let raw: String = g.iter().filter(|p| p.code == 3).map(|p| p.value).chain(g.raw(1)).collect();
let (s, family) = super::text::mtext(&raw);
if s.trim().is_empty() {
return None;
}
let height = g.num(40).filter(|h| *h > 0.0)?;
let leading = height * LINE_SPACING * g.num(44).filter(|f| *f > 0.0).unwrap_or(1.0);
let lines = s.lines().count().max(1) as f64;
let attach = g.int_or(71, 1).clamp(1, 9) - 1;
let justify = [Justify::Left, Justify::Center, Justify::Right].get((attach % 3) as usize).copied().unwrap_or_default();
// The first baseline below the attachment point: under the top, around the middle, above
// the bottom of the lines.
let total = height + (lines - 1.0) * leading;
let first = match attach / 3 {
0 => -height,
1 => total / 2.0 - height,
_ => (lines - 1.0) * leading,
};
let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12);
let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2());
let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first));
Some(TextItem {
text: s,
height,
xf,
width: 1.0,
justify,
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
family,
leading: Some(leading),
span: None,
})
}
+444
View File
@@ -0,0 +1,444 @@
//! Reading an ASCII DXF file: its text, the group-code pairs, and what the import uses of its
//! sections (header variables; the layer, linetype, text style and block record tables; the
//! blocks; the entities; the layouts).
use std::borrow::Cow;
use std::collections::HashMap;
use vectorcraft_geom::Point;
/// The most group-code pairs a drawing may hold (about 300 MB of DXF).
const MAX_PAIRS: usize = 20_000_000;
/// The first bytes of a binary DXF file (its format's signature).
pub const BINARY_SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\x00";
/// One group: its code and its value as written.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Pair<'a> {
pub code: i32,
pub value: &'a str,
}
/// Values of a run of pairs by group code.
pub(crate) trait Groups<'a> {
/// The value of the first `code`, as written.
fn raw(&self, code: i32) -> Option<&'a str>;
/// The first `code` as a name (trimmed, never empty).
fn name(&self, code: i32) -> Option<&'a str> {
self.raw(code).map(str::trim).filter(|s| !s.is_empty())
}
/// The first `code` as a finite number.
fn num(&self, code: i32) -> Option<f64> {
self.raw(code).and_then(number)
}
fn num_or(&self, code: i32, default: f64) -> f64 {
self.num(code).unwrap_or(default)
}
/// The first `code` as a whole number.
fn int(&self, code: i32) -> Option<i64> {
self.raw(code).and_then(integer)
}
fn int_or(&self, code: i32, default: i64) -> i64 {
self.int(code).unwrap_or(default)
}
/// The point at `code` (x) and `code + 10` (y); 0 for what is missing.
fn point(&self, code: i32) -> Point {
Point::new(self.num_or(code, 0.0), self.num_or(code + 10, 0.0))
}
/// [`Groups::point`] when its x is given.
fn point_opt(&self, code: i32) -> Option<Point> {
self.num(code).map(|x| Point::new(x, self.num_or(code + 10, 0.0)))
}
}
impl<'a> Groups<'a> for [Pair<'a>] {
fn raw(&self, code: i32) -> Option<&'a str> {
self.iter().find(|p| p.code == code).map(|p| p.value)
}
}
/// A finite number (`1e999`, `NaN` and junk are none).
pub(crate) fn number(s: &str) -> Option<f64> {
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
}
/// A whole number; some writers put integers as `1.0`.
pub(crate) fn integer(s: &str) -> Option<i64> {
let s = s.trim();
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15).map(|v| v as i64))
}
/// One object: its type (the value of its `0` group) and the groups up to the next object.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Record<'a> {
pub kind: &'a str,
pub g: &'a [Pair<'a>],
}
/// The text of a DXF file: UTF-8 (DXF 2007 and later), else the Windows code page older versions
/// write (a byte order mark is dropped).
pub(crate) fn decode(bytes: &[u8]) -> Cow<'_, str> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
match std::str::from_utf8(bytes) {
Ok(s) => Cow::Borrowed(s),
Err(_) => Cow::Owned(bytes.iter().map(|b| windows_1252(*b)).collect()),
}
}
/// A byte of the Windows Western code page.
fn windows_1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
'˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
_ => char::from(b),
}
}
/// The group-code pairs of `text`, up to `0 EOF`. A line that isn't a group code ends the
/// reading: what came before is kept and the second value says where the file is damaged.
pub(crate) fn pairs(text: &str) -> Result<(Vec<Pair<'_>>, Option<String>), String> {
let mut out = Vec::new();
let mut lines = text.lines().enumerate();
let mut damaged = None;
while let Some((n, code)) = lines.next() {
let c = code.trim();
if c.is_empty() && out.is_empty() {
continue;
}
let Ok(code) = c.parse::<i32>() else {
damaged = Some(format!("the file is damaged at line {}: what follows is left out", n + 1));
break;
};
let value = lines.next().map_or("", |(_, v)| v);
if code == 0 && value.trim() == "EOF" {
break;
}
if out.len() >= MAX_PAIRS {
return Err("the drawing is too large to open".into());
}
out.push(Pair { code, value });
}
if out.is_empty() {
return Err("not a DXF drawing".into());
}
Ok((out, damaged))
}
/// The objects in `pairs`: each from a `0` group up to the next.
fn records<'a>(pairs: &'a [Pair<'a>]) -> Vec<Record<'a>> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
for (i, p) in pairs.iter().enumerate() {
if p.code == 0 {
if let Some(s) = start {
out.extend(record(pairs, s, i));
}
start = Some(i);
}
}
if let Some(s) = start {
out.extend(record(pairs, s, pairs.len()));
}
out
}
fn record<'a>(pairs: &'a [Pair<'a>], start: usize, end: usize) -> Option<Record<'a>> {
let kind = pairs.get(start)?.value.trim();
Some(Record { kind, g: pairs.get(start + 1..end)? })
}
/// The header variables the import reads.
#[derive(Clone, Debug, Default)]
pub(crate) struct Header {
/// `$ACADVER` (`AC1032`…).
pub acadver: String,
/// `$INSUNITS`: the drawing unit (0: unitless).
pub insunits: i64,
/// `$MEASUREMENT`: 0 imperial, 1 metric.
pub measurement: Option<i64>,
/// `$LTSCALE`: the global linetype scale.
pub ltscale: f64,
/// `$LWDEFAULT`: the default lineweight (hundredths of a millimetre).
pub lwdefault: i32,
}
/// A layer of the LAYER table.
#[derive(Clone, Debug)]
pub(crate) struct LayerDef {
pub name: String,
pub rgb: [u8; 3],
/// Switched off (a negative colour) or frozen.
pub hidden: bool,
pub locked: bool,
pub plot: bool,
/// Its linetype (upper case).
pub linetype: String,
/// Hundredths of a millimetre; negative: the default.
pub lineweight: i32,
}
/// A block definition.
#[derive(Clone, Debug)]
pub(crate) struct BlockDef<'a> {
pub name: String,
pub base: Point,
pub records: Vec<Record<'a>>,
/// A reference to another drawing (its art isn't in this file).
pub xref: bool,
}
/// A layout: model space, or a paper layout with the block holding its art.
#[derive(Clone, Debug)]
pub(crate) struct LayoutDef {
pub name: String,
/// The block of a paper layout (upper case); `None` for model space.
pub block: Option<String>,
}
/// The name of model space.
pub(crate) const MODEL: &str = "Model";
const MODEL_BLOCK: &str = "*MODEL_SPACE";
const PAPER_BLOCK: &str = "*PAPER_SPACE";
/// A drawing as the import reads it.
#[derive(Debug, Default)]
pub(crate) struct Drawing<'a> {
pub header: Header,
pub layers: Vec<LayerDef>,
/// Index into `layers` by upper-case name.
layer_index: HashMap<String, usize>,
/// Linetype patterns by upper-case name: dash (positive), gap (negative), dot (0) lengths.
pub linetypes: HashMap<String, Vec<f64>>,
/// The font family of each text style (upper-case name; empty: a CAD font).
pub styles: HashMap<String, String>,
/// Blocks by upper-case name.
pub blocks: HashMap<String, BlockDef<'a>>,
/// The ENTITIES section.
pub entities: Vec<Record<'a>>,
/// Model space first, then the paper layouts in tab order.
pub layouts: Vec<LayoutDef>,
/// How many sections the file has (none: not a drawing).
pub sections: usize,
}
impl<'a> Drawing<'a> {
/// Read the sections of `pairs`.
pub fn read(pairs: &'a [Pair<'a>]) -> Self {
let mut d = Drawing { header: Header { ltscale: 1.0, lwdefault: 25, ..Header::default() }, ..Drawing::default() };
let all = records(pairs);
let mut section: Option<&str> = None;
let (mut tables, mut blocks, mut objects) = (vec![], vec![], vec![]);
for r in all {
match (r.kind, section) {
("SECTION", _) => {
d.sections += 1;
section = r.g.name(2);
if section == Some("HEADER") {
d.header = header(r.g);
}
}
("ENDSEC", _) => section = None,
(_, Some("TABLES")) => tables.push(r),
(_, Some("BLOCKS")) => blocks.push(r),
(_, Some("ENTITIES")) => d.entities.push(r),
(_, Some("OBJECTS")) => objects.push(r),
_ => {}
}
}
let mut block_records = HashMap::new();
let mut table = "";
for r in &tables {
match r.kind {
"TABLE" => table = r.g.name(2).unwrap_or(""),
"LAYER" if table == "LAYER" => {
if let Some(l) = layer(r.g) {
d.layer_index.entry(l.name.to_uppercase()).or_insert(d.layers.len());
d.layers.push(l);
}
}
"LTYPE" if table == "LTYPE" => {
if let Some(name) = r.g.name(2) {
let pattern: Vec<f64> = r.g.iter().filter(|p| p.code == 49).filter_map(|p| number(p.value)).collect();
d.linetypes.insert(name.to_uppercase(), pattern);
}
}
"STYLE" if table == "STYLE" => {
if let Some(name) = r.g.name(2) {
d.styles.insert(name.to_uppercase(), font_family(r.g));
}
}
"BLOCK_RECORD" if table == "BLOCK_RECORD" => {
if let (Some(h), Some(name)) = (r.g.name(5), r.g.name(2)) {
block_records.insert(h.to_uppercase(), name.to_uppercase());
}
}
_ => {}
}
}
d.read_blocks(&blocks, &mut block_records);
d.layouts = layouts(&objects, &block_records, &d.entities);
d
}
fn read_blocks(&mut self, records: &[Record<'a>], block_records: &mut HashMap<String, String>) {
let mut current: Option<BlockDef<'a>> = None;
for r in records {
match r.kind {
"BLOCK" => {
let name = r.g.name(2).or_else(|| r.g.name(3)).unwrap_or("").to_string();
// R13+ blocks name their block record: layouts find their block through it.
if let Some(owner) = r.g.name(330) {
block_records.entry(owner.to_uppercase()).or_insert_with(|| name.to_uppercase());
}
let xref = r.g.int_or(70, 0) & 4 != 0;
current = Some(BlockDef { name, base: r.g.point(10), records: vec![], xref });
}
"ENDBLK" => {
if let Some(b) = current.take().filter(|b| !b.name.is_empty()) {
self.blocks.insert(b.name.to_uppercase(), b);
}
}
_ => {
if let Some(b) = &mut current {
b.records.push(*r);
}
}
}
}
}
/// The layout named `name` (any case; `model` is model space).
pub fn layout(&self, name: &str) -> Option<&LayoutDef> {
self.layouts.iter().find(|l| l.name.eq_ignore_ascii_case(name.trim()))
}
/// The entity records of `layout`.
pub fn layout_records(&self, layout: &LayoutDef) -> Vec<Record<'a>> {
let block = |name: &str| self.blocks.get(name).map(|b| b.records.clone()).unwrap_or_default();
// The active paper layout keeps its art in the ENTITIES section, flagged paper space.
let (block_name, paper) = match &layout.block {
None => (MODEL_BLOCK, false),
Some(b) if b == PAPER_BLOCK => (PAPER_BLOCK, true),
Some(b) => return block(b),
};
let mut out: Vec<Record<'a>> = groups(&self.entities).filter(|g| in_paper_space(g) == paper).flatten().copied().collect();
out.extend(block(block_name));
out
}
/// The layer named `name` (any case).
pub fn layer(&self, name: &str) -> Option<&LayerDef> {
self.layer_index.get(&name.to_uppercase()).and_then(|i| self.layers.get(*i))
}
}
/// Is an entity (its first record) in paper space?
fn in_paper_space(group: &[Record<'_>]) -> bool {
group.first().is_some_and(|r| r.g.int(67) == Some(1))
}
/// The entities of `records`, each with the records that belong to it (a polyline's vertices,
/// an insert's attributes, and the SEQEND closing them).
pub(crate) fn groups<'r, 'a>(records: &'r [Record<'a>]) -> impl Iterator<Item = &'r [Record<'a>]> {
let mut i = 0;
std::iter::from_fn(move || {
let start = i;
let head = records.get(start)?;
i += 1;
if matches!(head.kind, "POLYLINE" | "INSERT") {
while let Some(r) = records.get(i) {
match r.kind {
"VERTEX" | "ATTRIB" => i += 1,
"SEQEND" => {
i += 1;
break;
}
_ => break,
}
}
}
records.get(start..i)
})
}
fn header(g: &[Pair<'_>]) -> Header {
let mut h = Header { ltscale: 1.0, lwdefault: 25, ..Header::default() };
let mut var = "";
for p in g {
if p.code == 9 {
var = p.value.trim();
continue;
}
match (var, p.code) {
("$ACADVER", 1) => h.acadver = p.value.trim().to_string(),
("$INSUNITS", 70) => h.insunits = integer(p.value).unwrap_or(0),
("$MEASUREMENT", 70) => h.measurement = integer(p.value),
("$LTSCALE", 40) => h.ltscale = number(p.value).filter(|v| *v > 0.0).unwrap_or(1.0),
("$LWDEFAULT", 370) => h.lwdefault = integer(p.value).and_then(|v| i32::try_from(v).ok()).filter(|v| *v >= 0).unwrap_or(25),
_ => {}
}
}
h
}
fn layer(g: &[Pair<'_>]) -> Option<LayerDef> {
let name = g.name(2)?.to_string();
let aci = g.int_or(62, 7);
let flags = g.int_or(70, 0);
let rgb = super::entity::true_color(g).unwrap_or_else(|| super::entity::aci_paper(aci.unsigned_abs()));
Some(LayerDef {
// CAD apps never plot this layer of dimension points.
plot: g.int_or(290, 1) != 0 && !name.eq_ignore_ascii_case("Defpoints"),
name,
rgb,
hidden: aci < 0 || flags & 1 != 0,
locked: flags & 4 != 0,
linetype: g.name(6).unwrap_or("CONTINUOUS").to_uppercase(),
lineweight: g.int(370).and_then(|v| i32::try_from(v).ok()).unwrap_or(-3),
})
}
/// A text style's font family: the one 2000-and-later files name, else the font file's name
/// (TrueType); CAD fonts (`.shx`) have none.
fn font_family(g: &[Pair<'_>]) -> String {
if let Some(i) = g.iter().position(|p| p.code == 1001 && p.value.trim() == "ACAD")
&& let Some(f) = g.get(i + 1..).and_then(|rest| rest.name(1000))
{
return f.to_string();
}
let file = g.name(3).unwrap_or("");
let path = std::path::Path::new(file);
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
match ext.as_str() {
"ttf" | "otf" | "ttc" => path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(),
_ => String::new(),
}
}
/// Model space, then each paper layout (its LAYOUT object, in tab order); a file without layout
/// objects but with paper-space art has one paper layout.
fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, entities: &[Record<'_>]) -> Vec<LayoutDef> {
let mut paper: Vec<(i64, LayoutDef)> = vec![];
for r in objects.iter().filter(|r| r.kind == "LAYOUT") {
// The layout's own groups follow its subclass marker.
let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]);
let Some(name) = own.name(1) else { continue };
let block = own.name(330).and_then(|h| block_records.get(&h.to_uppercase())).cloned();
if name.eq_ignore_ascii_case(MODEL) || block.as_deref() == Some(MODEL_BLOCK) {
continue;
}
let Some(block) = block else { continue };
if !paper.iter().any(|(_, l)| l.name.eq_ignore_ascii_case(name)) {
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) }));
}
}
paper.sort_by_key(|(order, _)| *order);
if paper.is_empty() && groups(entities).any(in_paper_space) {
paper.push((1, LayoutDef { name: "Layout1".into(), block: Some(PAPER_BLOCK.into()) }));
}
std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect()
}
+904
View File
@@ -0,0 +1,904 @@
//! DXF import: a fixture per entity, bulge and spline accuracy, blocks as symbols, layers,
//! colours, lineweights, layouts, the options, damaged and binary files, and drawings the export
//! wrote read back.
use std::sync::Arc;
use kurbo::ParamCurve;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Justify, Node, NodeKind, TextObject, Unit};
use vectorcraft_geom::{Affine, FillRule, Point, Rect, shapes};
use super::*;
/// A DXF file from `(code, value)` pairs written one per line.
fn file(pairs: &[(i32, &str)]) -> Vec<u8> {
let mut s = String::new();
for (c, v) in pairs {
s.push_str(&format!("{c:>3}\n{v}\n"));
}
s.push_str(" 0\nEOF\n");
s.into_bytes()
}
/// A drawing with `header` variables, `tables`, `blocks` and `entities` (each a run of pairs).
fn drawing(header: &[(i32, &str)], tables: &[(i32, &str)], blocks: &[(i32, &str)], entities: &[(i32, &str)]) -> Vec<u8> {
let mut p: Vec<(i32, &str)> = vec![(0, "SECTION"), (2, "HEADER")];
p.extend_from_slice(header);
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "TABLES")]);
p.extend_from_slice(tables);
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "BLOCKS")]);
p.extend_from_slice(blocks);
p.extend([(0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES")]);
p.extend_from_slice(entities);
p.push((0, "ENDSEC"));
file(&p)
}
fn entities(e: &[(i32, &str)]) -> Vec<u8> {
drawing(&[], &[], &[], e)
}
/// Points at 1:1, not centred: drawing (x, y) lands at document (x, artboard height − y).
fn points() -> ImportOptions {
ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() }
}
fn open(bytes: &[u8], o: &ImportOptions) -> Imported {
import(bytes, o).unwrap()
}
/// Every leaf object, in paint order.
fn leaves(doc: &Document) -> Vec<Node> {
let mut out = vec![];
doc.walk(|n| {
if n.children().is_none() {
out.push(n.clone());
}
});
out
}
/// The artboard's height (document y = height − drawing y).
fn height(doc: &Document) -> f64 {
doc.artboards[0].rect.height()
}
fn path_points(n: &Node) -> Vec<Point> {
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path: {:?}", n.kind) };
path.to_bezpath().segments().flat_map(|s| (0..=16).map(move |i| s.eval(f64::from(i) / 16.0))).collect()
}
fn bounds(n: &Node) -> Rect {
n.geometric_bounds().unwrap()
}
fn close(a: Rect, b: Rect, tol: f64) -> bool {
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol)
}
fn rgba(p: Option<&Paint>) -> [u8; 4] {
match p {
Some(Paint::Solid { color, .. }) => color.to_rgba8(1.0),
other => panic!("not a solid paint: {other:?}"),
}
}
fn stroke_rgb(n: &Node) -> [u8; 4] {
rgba(n.appearance.stroke().map(|s| &s.paint))
}
fn fill_rgb(n: &Node) -> [u8; 4] {
rgba(n.appearance.fill().map(|f| &f.paint))
}
fn text_of(n: &Node) -> &TextObject {
let NodeKind::Text(t) = &n.kind else { panic!("not text: {:?}", n.kind) };
t
}
const LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "10"), (20, "20"), (11, "110"), (21, "70")];
#[test]
fn a_line_comes_in_at_the_drawings_unit_and_origin() {
let doc = open(&entities(LINE), &points()).document;
let [line] = &leaves(&doc)[..] else { panic!() };
let board = doc.artboards[0].rect;
assert_eq!((board.width(), board.height()), (100.0, 50.0), "the artboard is the art's size");
// Not centred: the origin sits on the artboard's bottom-left corner.
let pts = path_points(line);
assert!(pts.first().unwrap().distance(Point::new(10.0, 50.0 - 20.0)) < 1e-9, "{pts:?}");
assert!(pts.last().unwrap().distance(Point::new(110.0, 50.0 - 70.0)) < 1e-9);
assert_eq!(stroke_rgb(line), [0, 0, 0, 255], "colour 7 is black on paper");
let w = line.appearance.stroke().unwrap().width;
assert!((w - 0.25 * 72.0 / 25.4).abs() < 1e-9, "the default lineweight is 0.25 mm: {w}");
assert!(line.appearance.fill().is_none_or(|f| f.paint.is_none()));
}
#[test]
fn units_choose_the_default_scale() {
// Millimetres at 1:1: 100 units are 100 mm.
let mm = drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], LINE);
let doc = open(&mm, &ImportOptions::default()).document;
assert!((doc.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6);
assert_eq!(doc.units, Unit::Millimeters);
// Inches; microns (1 mm = 1000 units); unitless imperial drawings read inches.
let inch = open(&drawing(&[(9, "$INSUNITS"), (70, "1")], &[], &[], LINE), &ImportOptions::default()).document;
assert!((inch.artboards[0].rect.width() - 7200.0).abs() < 1e-6);
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);
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.
let o = ImportOptions { unit: Some(Unit::Centimeters), scale: Some(10.0), ..ImportOptions::default() };
let cm = open(&mm, &o).document;
assert!((cm.artboards[0].rect.width() - 100.0 * 72.0 / 25.4).abs() < 1e-6);
assert_eq!(cm.units, Unit::Centimeters);
}
#[test]
fn circles_arcs_and_ellipses() {
let e = [
(0, "CIRCLE"),
(10, "50"),
(20, "50"),
(40, "25"),
(0, "ARC"),
(10, "200"),
(20, "50"),
(40, "20"),
(50, "0"),
(51, "90"),
(0, "ELLIPSE"),
(10, "400"),
(20, "50"),
(11, "40"),
(21, "0"),
(40, "0.5"),
(41, "0"),
(42, "6.283185307179586"),
];
let doc = open(&entities(&e), &points()).document;
let h = height(&doc);
let flip = |r: Rect| Rect::new(r.x0, h - r.y1, r.x1, h - r.y0);
let [circle, arc, ellipse] = &leaves(&doc)[..] else { panic!() };
assert!(close(bounds(circle), flip(Rect::new(25.0, 25.0, 75.0, 75.0)), 1e-3), "{:?}", bounds(circle));
for p in path_points(circle) {
assert!((p.distance(Point::new(50.0, h - 50.0)) - 25.0).abs() < 1e-4);
}
// A quarter arc counter-clockwise from 0° to 90°.
assert!(close(bounds(arc), flip(Rect::new(200.0, 50.0, 220.0, 70.0)), 1e-3), "{:?}", bounds(arc));
assert!(close(bounds(ellipse), flip(Rect::new(360.0, 30.0, 440.0, 70.0)), 1e-3), "{:?}", bounds(ellipse));
let NodeKind::Path { path, .. } = &circle.kind else { panic!() };
assert!(path.is_closed());
}
#[test]
fn polyline_bulges_are_exact_arcs() {
// A slot: two straight sides and two half circles (bulge 1) of radius 10.
let e = [
(0, "LWPOLYLINE"),
(90, "4"),
(70, "1"),
(10, "0"),
(20, "0"),
(10, "100"),
(20, "0"),
(42, "1"),
(10, "100"),
(20, "20"),
(10, "0"),
(20, "20"),
(42, "1"),
];
let doc = open(&entities(&e), &points()).document;
let [slot] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
// The arcs bulge outward, so the art is 120 wide.
assert!(close(bounds(slot), Rect::new(-10.0, h - 20.0, 110.0, h), 1e-4), "{:?}", bounds(slot));
for p in path_points(slot).into_iter().filter(|p| p.x > 100.0) {
let err = (p.distance(Point::new(100.0, h - 10.0)) - 10.0).abs();
assert!(err < 1e-4, "bulge error {err}");
}
// Old-style polylines read their vertices and bulges too.
let e = [
(0, "POLYLINE"),
(66, "1"),
(70, "0"),
(0, "VERTEX"),
(10, "0"),
(20, "0"),
(42, "-1"),
(0, "VERTEX"),
(10, "20"),
(20, "0"),
(0, "SEQEND"),
(0, "LINE"),
(10, "0"),
(20, "0"),
(11, "0"),
(21, "-30"),
];
let doc = open(&entities(&e), &points()).document;
let [arc, _] = &leaves(&doc)[..] else { panic!() };
// Clockwise from (0, 0) to (20, 0): over the chord, 10 high.
let b = bounds(arc);
assert!((b.width() - 20.0).abs() < 1e-4 && (b.height() - 10.0).abs() < 1e-4, "{b:?}");
assert!((b.y1 - height(&doc)).abs() < 1e-4, "the arc's chord is at y 0: {b:?}");
}
#[test]
fn nurbs_splines_stay_within_a_hundredth() {
// A rational quadratic circle of radius 100: nine weighted control points.
let w = std::f64::consts::FRAC_1_SQRT_2.to_string();
let mut e: Vec<(i32, String)> = vec![(0, "SPLINE".into()), (70, "12".into()), (71, "2".into()), (72, "12".into()), (73, "9".into())];
for k in ["0", "0", "0", "1", "1", "2", "2", "3", "3", "4", "4", "4"] {
e.push((40, k.into()));
}
for i in 0..9 {
e.push((41, if i % 2 == 0 { "1".into() } else { w.clone() }));
}
for (x, y) in [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1), (1, 0)] {
e.extend([(10, (200 + x * 100).to_string()), (20, (200 + y * 100).to_string()), (30, "0".into())]);
}
let e: Vec<(i32, &str)> = e.iter().map(|(c, v)| (*c, v.as_str())).collect();
let doc = open(&entities(&e), &points()).document;
let [circle] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
let worst = path_points(circle).iter().map(|p| (p.distance(Point::new(200.0, h - 200.0)) - 100.0).abs()).fold(0.0, f64::max);
assert!(worst < 0.01, "NURBS error {worst}");
// A spline given by fit points alone passes through them.
let e = [(0, "SPLINE"), (71, "3"), (74, "3"), (11, "0"), (21, "0"), (11, "50"), (21, "40"), (11, "100"), (21, "0")];
let doc = open(&entities(&e), &points()).document;
let [s] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
assert!(path_points(s).iter().any(|p| p.distance(Point::new(50.0, h - 40.0)) < 1e-9));
}
/// A square with a round hole: a polyline loop and a loop of one edge (a whole arc).
fn hatch(solid: &'static str) -> Vec<(i32, &'static str)> {
vec![
(0, "HATCH"),
(62, "1"),
(10, "0"),
(20, "0"),
(30, "0"),
(2, if solid == "1" { "SOLID" } else { "ANSI31" }),
(70, solid),
(71, "0"),
(91, "2"),
(92, "3"),
(72, "0"),
(73, "1"),
(93, "4"),
(10, "0"),
(20, "0"),
(10, "100"),
(20, "0"),
(10, "100"),
(20, "100"),
(10, "0"),
(20, "100"),
(97, "0"),
(92, "0"),
(93, "1"),
(72, "2"),
(10, "50"),
(20, "50"),
(40, "20"),
(50, "0"),
(51, "360"),
(73, "1"),
(97, "0"),
(75, "0"),
(76, "1"),
(98, "0"),
]
}
#[test]
fn solid_hatches_fill_and_pattern_hatches_outline() {
let r = open(&entities(&hatch("1")), &points());
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let compound = r.document.layers[0].children().unwrap()[0].clone();
let NodeKind::Compound { children, rule } = &compound.kind else { panic!("{:?}", compound.kind) };
assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd), "the hole is a second loop");
assert_eq!(fill_rgb(&compound), [255, 0, 0, 255]);
assert!(compound.appearance.stroke().is_none_or(|s| s.paint.is_none()));
assert_eq!(bounds(&compound).area(), 10_000.0);
let r = open(&entities(&hatch("0")), &points());
assert!(r.warnings.iter().any(|w| w.contains("pattern hatches")), "{:?}", r.warnings);
let outline = r.document.layers[0].children().unwrap()[0].clone();
assert_eq!(stroke_rgb(&outline), [255, 0, 0, 255]);
// Solids fill their corners in zigzag order.
let e = [(0, "SOLID"), (10, "0"), (20, "0"), (11, "10"), (21, "0"), (12, "0"), (22, "10"), (13, "10"), (23, "10")];
let doc = open(&entities(&e), &points()).document;
let [square] = &leaves(&doc)[..] else { panic!() };
assert_eq!(bounds(square).area(), 100.0);
assert!(square.appearance.fill().is_some_and(|f| !f.paint.is_none()));
}
/// A solid hatch: a loop of a line and a spline edge (`fit` its fit data, if any), followed by
/// `source` (the loop's source-object count and handles), and a square hole.
fn spline_edged_hatch(fit: &[(i32, &'static str)], source: &[(i32, &'static str)]) -> Vec<(i32, &'static str)> {
let mut e = vec![(0, "HATCH"), (62, "1"), (10, "0"), (20, "0"), (30, "0"), (2, "SOLID"), (70, "1"), (71, "1"), (91, "2")];
e.extend([(92, "1"), (93, "2"), (72, "1"), (10, "0"), (20, "0"), (11, "100"), (21, "0")]);
e.extend([(72, "4"), (94, "3"), (73, "0"), (74, "0"), (95, "8"), (96, "4")]);
e.extend(["0", "0", "0", "0", "1", "1", "1", "1"].map(|k| (40, k)));
e.extend([(10, "100"), (20, "0"), (10, "100"), (20, "100"), (10, "0"), (20, "100"), (10, "0"), (20, "0")]);
e.extend_from_slice(fit);
e.extend_from_slice(source);
e.extend([(92, "2"), (72, "0"), (73, "1"), (93, "4"), (10, "40"), (20, "20"), (10, "60"), (20, "20")]);
e.extend([(10, "60"), (20, "40"), (10, "40"), (20, "40"), (97, "0"), (75, "0"), (76, "1"), (98, "0")]);
e
}
#[test]
fn hatch_spline_edges_with_or_without_fit_data() {
// Before DXF 2010 a spline edge has no fit data, so a 97 right after it is the loop's own
// count of source objects; from 2010 on it has a fit count, here 0, before that.
let source = [(97, "1"), (330, "1F")];
for fit in [&[][..], &[(97, "0")][..]] {
let r = open(&entities(&spline_edged_hatch(fit, &source)), &points());
let [hatch] = &r.document.layers[0].children().unwrap()[..] else { panic!("fit data {fit:?}: the hatch is lost") };
let NodeKind::Compound { children, .. } = &hatch.kind else { panic!("{:?}", hatch.kind) };
assert_eq!(children.len(), 2, "the outline and the hole, fit data {fit:?}");
// The spline edge peaks three quarters of the way up its control polygon.
let b = bounds(hatch);
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 75.0).abs() < 1e-6, "{b:?}");
}
// Fit points after the count are still read (and ignored when there are control points).
let fit = [(97, "2"), (11, "100"), (21, "0"), (11, "0"), (21, "0")];
let r = open(&entities(&spline_edged_hatch(&fit, &source)), &points());
assert_eq!(r.document.layers[0].children().unwrap().len(), 1);
}
#[test]
fn text_and_multiline_text() {
let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Notes"), (3, "DejaVuSans.ttf"), (0, "ENDTAB")];
let e = [
(0, "TEXT"),
(10, "10"),
(20, "10"),
(40, "7"),
(1, "Room %%c20"),
(50, "90"),
(7, "Notes"),
(0, "MTEXT"),
(10, "100"),
(20, "100"),
(40, "3.5"),
(71, "2"),
(3, "{\\fArial|b0;First}"),
(1, "\\PSecond"),
];
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
let [single, multi] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
let t = text_of(single);
assert_eq!(t.plain_text(), "Room ⌀20");
let st = t.first_style();
assert!((st.size - 10.0).abs() < 1e-9, "a 7-unit cap height is 10 pt type: {}", st.size);
assert_eq!(st.font_family, "DejaVuSans");
// Turned a quarter counter-clockwise, its baseline starting at the insertion point.
let origin = t.xf * Point::ZERO;
assert!(origin.distance(Point::new(10.0, h - 10.0)) < 1e-9, "{origin:?}");
let along = t.xf * Point::new(1.0, 0.0) - origin;
assert!(along.x.abs() < 1e-9 && (along.y + 1.0).abs() < 1e-9, "{along:?}");
let m = text_of(multi);
assert_eq!(m.plain_text(), "First\nSecond");
assert_eq!(m.para.justify, Justify::Center, "attached top centre");
assert_eq!(m.first_style().font_family, "Arial");
// Top attachment: the first baseline one cap height below the insertion point.
let o = m.xf * Point::ZERO;
assert!(o.distance(Point::new(100.0, h - 100.0 + 3.5)) < 1e-9, "{o:?}");
let leading = m.first_style().leading.unwrap();
assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}");
}
/// Where the first line's baseline starts and ends, in document coordinates.
fn baseline_ends(t: &TextObject) -> (Point, Point) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let l = &layout.lines[0];
(t.xf * Point::new(l.x0, l.baseline), t.xf * Point::new(l.x1, l.baseline))
}
#[test]
fn fit_and_aligned_text_span_their_two_points() {
// Millimetres: red lines at x = 10 and 90, fit and aligned text between them, and the same
// text left-justified for its natural size.
let e = [
(0, "LINE"),
(62, "1"),
(10, "10"),
(20, "0"),
(11, "10"),
(21, "60"),
(0, "LINE"),
(62, "1"),
(10, "90"),
(20, "0"),
(11, "90"),
(21, "60"),
(0, "TEXT"),
(10, "10"),
(20, "40"),
(40, "5"),
(1, "FIT BETWEEN"),
(72, "5"),
(11, "90"),
(21, "40"),
(0, "TEXT"),
(10, "10"),
(20, "10"),
(40, "5"),
(41, "0.8"),
(1, "ALIGNED"),
(72, "3"),
(11, "90"),
(21, "10"),
(0, "TEXT"),
(10, "10"),
(20, "25"),
(40, "5"),
(1, "FIT BETWEEN"),
(0, "TEXT"),
(10, "10"),
(20, "50"),
(40, "5"),
(41, "0.8"),
(1, "ALIGNED"),
];
let o = ImportOptions { center: false, ..ImportOptions::default() };
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
let [left, right, fit, aligned, fit_natural, aligned_natural] = &leaves(&doc)[..] else { panic!() };
let (x10, x90) = (bounds(left).center().x, bounds(right).center().x);
for n in [fit, aligned] {
let (a, b) = baseline_ends(text_of(n));
assert!((a.x - x10).abs() < 1e-3 && (b.x - x90).abs() < 1e-3, "{a:?} to {b:?}, not {x10} to {x90}");
assert!((a.y - b.y).abs() < 1e-9, "level");
}
// Fit keeps its height and stretches across.
let (st, natural) = (text_of(fit).first_style(), text_of(fit_natural).first_style());
assert_eq!(st.size, natural.size);
assert!(st.h_scale > 100.0, "{}", st.h_scale);
// Aligned grows as a whole, its width factor kept.
let (a, b) = baseline_ends(text_of(aligned_natural));
let k = (x90 - x10) / (b.x - a.x);
let (st, natural) = (text_of(aligned).first_style(), text_of(aligned_natural).first_style());
assert!((st.size - natural.size * k).abs() < 1e-6 && k > 2.0, "{} vs {} × {k}", st.size, natural.size);
assert!((st.h_scale - 80.0).abs() < 1e-9 && (natural.h_scale - 80.0).abs() < 1e-9);
}
#[test]
fn fit_and_aligned_text_without_a_second_point_keep_their_size() {
for h in ["3", "5"] {
let e = [
(0, "TEXT"),
(10, "10"),
(20, "10"),
(40, "7"),
(1, "Gap"),
(72, h),
(0, "TEXT"),
(10, "10"),
(20, "30"),
(40, "7"),
(1, "Gap"),
(72, h),
(11, "10"),
(21, "30"),
];
let doc = open(&entities(&e), &points()).document;
let top = height(&doc);
for (n, y) in leaves(&doc).iter().zip([10.0, 30.0]) {
let t = text_of(n);
let st = t.first_style();
assert!((st.size - 10.0).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
assert!((t.xf * Point::ZERO).distance(Point::new(10.0, top - y)) < 1e-9);
}
}
}
#[test]
fn fit_and_aligned_text_scale_within_limits() {
// A span a billion units long and one a millionth of a unit long.
let e = [
(0, "TEXT"),
(10, "0"),
(20, "0"),
(40, "7"),
(1, "X"),
(72, "5"),
(11, "1e9"),
(21, "0"),
(0, "TEXT"),
(10, "0"),
(20, "10"),
(40, "7"),
(1, "X"),
(72, "3"),
(11, "1e-6"),
(21, "10"),
];
let doc = open(&entities(&e), &points()).document;
let [fit, aligned] = &leaves(&doc)[..] else { panic!() };
let st = text_of(fit).first_style();
assert!((st.size - 10.0).abs() < 1e-9 && (st.h_scale - 10_000.0).abs() < 1e-6, "{} at {}%", st.size, st.h_scale);
let st = text_of(aligned).first_style();
assert!((st.size - 0.1).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
}
#[test]
fn fit_attributes_span_their_two_points() {
let inserts = [
(0, "INSERT"),
(66, "1"),
(2, "Door"),
(10, "0"),
(20, "0"),
(0, "ATTRIB"),
(10, "10"),
(20, "20"),
(40, "7"),
(1, "D-01"),
(2, "TAG"),
(70, "0"),
(72, "5"),
(74, "0"),
(11, "90"),
(21, "20"),
(0, "SEQEND"),
];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
let tag = leaves(&doc).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
let (a, b) = baseline_ends(text_of(&tag));
assert!((a.x - 10.0).abs() < 1e-3 && (b.x - 90.0).abs() < 1e-3, "{a:?} to {b:?}");
assert_eq!(text_of(&tag).first_style().size, 10.0);
}
/// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`.
fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec<u8> {
let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")];
blocks.extend_from_slice(block_art);
blocks.push((0, "ENDBLK"));
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "Doors"), (70, "0"), (62, "5"), (6, "CONTINUOUS"), (0, "ENDTAB")];
drawing(&[], &tables, &blocks, inserts)
}
/// A red line 10 units along x from the base point, on layer 0.
const DOOR_LINE: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "1"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
#[test]
fn inserts_become_symbol_instances_with_their_transform() {
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
assert_eq!(doc.symbols.len(), 1);
assert_eq!(doc.symbols[0].name, "Door");
let [inst] = &leaves(&doc)[..] else { panic!() };
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { panic!("{:?}", inst.kind) };
assert_eq!(symbol, "Door");
assert_eq!(doc.layers[0].name.as_deref(), Some("Doors"));
// Scaled 2 and turned a quarter: from the insertion point 20 units up.
let art = &doc.symbols[0].art;
let pts: Vec<Point> = path_points(art).iter().map(|p| *xf * *p).collect();
let h = height(&doc);
let (a, b) = (pts.first().unwrap(), pts.last().unwrap());
assert!(a.distance(Point::new(100.0, h - 50.0)) < 1e-9, "{a:?}");
assert!(b.distance(Point::new(100.0, h - 70.0)) < 1e-9, "{b:?}");
assert_eq!(stroke_rgb(art), [255, 0, 0, 255], "the block's own colour");
}
#[test]
fn by_block_colours_make_a_symbol_per_look() {
// The block's line takes the insert's colour: red and green make two symbols; a third red
// insert reuses the first.
let by_block: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
let inserts = [
(0, "INSERT"),
(2, "Door"),
(62, "1"),
(10, "0"),
(20, "0"),
(0, "INSERT"),
(2, "Door"),
(62, "3"),
(10, "50"),
(20, "0"),
(0, "INSERT"),
(2, "Door"),
(62, "1"),
(10, "100"),
(20, "0"),
];
let doc = open(&with_door(by_block, &inserts), &points()).document;
let names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["Door", "Door 2"]);
assert_eq!(stroke_rgb(&doc.symbols[0].art), [255, 0, 0, 255]);
assert_eq!(stroke_rgb(&doc.symbols[1].art), [0, 255, 0, 255]);
assert_eq!(leaves(&doc).len(), 3);
// Layer 0 art takes the insert's layer's colour.
let on_zero: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "0"), (20, "0"), (0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "40"), (20, "0")];
let doc = open(&with_door(on_zero, &inserts), &points()).document;
assert_eq!(doc.symbols.len(), 1);
assert_eq!(stroke_rgb(&doc.symbols[0].art), [0, 0, 255, 255], "layer Doors is blue");
// Art with its own colour shares one symbol, whatever the inserts' colours.
let inserts = [(0, "INSERT"), (2, "Door"), (62, "1"), (10, "0"), (20, "0"), (0, "INSERT"), (2, "Door"), (62, "3"), (10, "40"), (20, "0")];
let doc = open(&with_door(&[(0, "LINE"), (8, "Doors"), (62, "4"), (10, "5"), (20, "5"), (11, "15"), (21, "5")], &inserts), &points()).document;
assert_eq!(doc.symbols.len(), 1);
}
#[test]
fn array_inserts_attributes_and_dimensions() {
let inserts = [
(0, "INSERT"),
(66, "1"),
(2, "Door"),
(10, "0"),
(20, "0"),
(70, "3"),
(71, "2"),
(44, "20"),
(45, "30"),
(0, "ATTRIB"),
(10, "0"),
(20, "-10"),
(40, "2"),
(1, "D-01"),
(2, "TAG"),
(70, "0"),
(0, "ATTRIB"),
(10, "0"),
(20, "-20"),
(40, "2"),
(1, "hidden"),
(2, "NOTE"),
(70, "1"),
(0, "SEQEND"),
];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
let all = leaves(&doc);
let instances: Vec<Point> =
all.iter().filter_map(|n| if let NodeKind::SymbolInstance { xf, .. } = &n.kind { Some(*xf * Point::ZERO) } else { None }).collect();
assert_eq!(instances.len(), 6, "3 columns × 2 rows");
let xs: std::collections::BTreeSet<i64> = instances.iter().map(|p| p.x.round() as i64).collect();
assert_eq!(xs.len(), 3, "columns 20 apart: {instances:?}");
let texts: Vec<String> = all.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
assert_eq!(texts, ["D-01"], "invisible attributes stay out");
// A dimension draws its anonymous block as art, not a symbol.
let blocks = [(0, "BLOCK"), (2, "*D1"), (70, "1"), (10, "0"), (20, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "50"), (21, "0"), (0, "ENDBLK")];
let dims = [(0, "DIMENSION"), (2, "*D1"), (10, "0"), (20, "0")];
let doc = open(&drawing(&[], &[], &blocks, &dims), &points()).document;
assert!(doc.symbols.is_empty());
assert_eq!(bounds(&leaves(&doc)[0]).width(), 50.0);
}
#[test]
fn blocks_inside_themselves_are_left_out_without_hanging() {
let blocks = [
(0, "BLOCK"),
(2, "Loop"),
(10, "0"),
(20, "0"),
(0, "LINE"),
(10, "0"),
(20, "0"),
(11, "10"),
(21, "0"),
(0, "INSERT"),
(2, "Loop"),
(10, "20"),
(20, "0"),
(0, "ENDBLK"),
];
let r = open(&drawing(&[], &[], &blocks, &[(0, "INSERT"), (2, "Loop"), (10, "0"), (20, "0")]), &points());
assert!(r.warnings.iter().any(|w| w.contains("inside themselves")), "{:?}", r.warnings);
assert_eq!(r.document.symbols.len(), 1);
}
#[test]
fn layers_keep_their_state_colour_lineweight_and_linetype() {
let tables = [
(0, "TABLE"),
(2, "LTYPE"),
(0, "LTYPE"),
(2, "DASHED"),
(73, "2"),
(40, "15"),
(49, "10"),
(49, "-5"),
(0, "ENDTAB"),
(0, "TABLE"),
(2, "LAYER"),
(0, "LAYER"),
(2, "Hidden"),
(70, "0"),
(62, "-3"),
(6, "DASHED"),
(370, "50"),
(0, "LAYER"),
(2, "Frozen"),
(70, "1"),
(62, "2"),
(0, "LAYER"),
(2, "Locked"),
(70, "4"),
(62, "1"),
(420, "16744448"),
(290, "0"),
(0, "ENDTAB"),
];
let line = |layer: &'static str| [(0, "LINE"), (8, layer), (10, "0"), (20, "0"), (11, "100"), (21, "0")];
let e: Vec<(i32, &str)> = [line("Hidden"), line("Frozen"), line("Locked"), line("Unlisted")].concat();
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
let names: Vec<&str> = doc.layers.iter().filter_map(|l| l.name.as_deref()).collect();
assert_eq!(names, ["Hidden", "Frozen", "Locked", "Unlisted"]);
let [hidden, frozen, locked, plain] = &doc.layers[..] else { panic!() };
assert!(!hidden.visible && !frozen.visible && locked.visible && plain.visible);
assert!(locked.locked && !hidden.locked);
assert!(matches!(locked.kind, NodeKind::Layer { printable: false, .. }));
let first = |l: &Arc<Node>| l.children().unwrap()[0].clone();
// Colour 3 (green) and a 0.5 mm dashed line from the layer.
let h = first(hidden);
assert_eq!(stroke_rgb(&h), [0, 255, 0, 255]);
let st = h.appearance.stroke().unwrap();
assert!((st.width - 0.5 * 72.0 / 25.4).abs() < 1e-9);
assert_eq!(st.dash.as_ref().unwrap().pattern, [10.0, 5.0]);
assert_eq!(stroke_rgb(&first(frozen)), [255, 255, 0, 255]);
assert_eq!(stroke_rgb(&first(locked)), [255, 128, 0, 255], "true colour wins over the index");
// Merge Layers: one layer.
let merged = open(&drawing(&[], &tables, &[], &e), &ImportOptions { merge_layers: true, ..points() }).document;
assert_eq!(merged.layers.len(), 1);
assert_eq!(merged.layers[0].children().unwrap().len(), 4);
}
#[test]
fn entity_colours_lineweights_and_transparency() {
let e = [
(0, "LINE"),
(62, "30"),
(370, "100"),
(440, "33554559"),
(10, "0"),
(20, "0"),
(11, "10"),
(21, "0"),
(0, "LINE"),
(62, "5"),
(420, "65280"),
(370, "0"),
(10, "0"),
(20, "10"),
(11, "10"),
(21, "10"),
];
let doc = open(&entities(&e), &points()).document;
let [a, b] = &leaves(&doc)[..] else { panic!() };
assert_eq!(stroke_rgb(a), [255, 127, 0, 255], "index 30 is orange");
assert!((a.appearance.stroke().unwrap().width - 72.0 / 25.4).abs() < 1e-9, "1 mm");
assert!((a.opacity - 127.0 / 255.0).abs() < 1e-6);
assert_eq!(stroke_rgb(b), [0, 255, 0, 255]);
assert_eq!(b.appearance.stroke().unwrap().width, 0.25, "lineweight 0 is the thinnest line");
// Scale Lineweights: at 1 pt = 10 units (a tenth of 1:1 in millimetres) they shrink too.
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(10.0), scale_lineweights: true, ..ImportOptions::default() };
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
let w = leaves(&doc)[0].appearance.stroke().unwrap().width;
let k = 0.1 / (72.0 / 25.4);
assert!((w - 72.0 / 25.4 * k).abs() < 1e-9, "{w}");
}
#[test]
fn fit_and_centre_place_the_art_on_the_artboard() {
let doc = open(&entities(LINE), &ImportOptions { fit: true, ..ImportOptions::default() }).document;
let board = doc.artboards[0].rect;
assert_eq!((board.width(), board.height()), (792.0, 612.0), "landscape letter for wide art");
let b = bounds(&leaves(&doc)[0]);
assert!((b.width() - 792.0).abs() < 1e-6 && (b.center().y - 306.0).abs() < 1e-6, "{b:?}");
let o = ImportOptions { fit: true, fit_to: (200.0, 100.0), center: false, ..ImportOptions::default() };
let b = bounds(&leaves(&open(&entities(LINE), &o).document)[0]);
assert!(close(b, Rect::new(0.0, 0.0, 200.0, 100.0), 1e-6), "bottom-left: {b:?}");
// Centred at a ratio, the artboard is the art's bounds.
let doc = open(&entities(LINE), &ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), ..ImportOptions::default() }).document;
assert!(close(bounds(&leaves(&doc)[0]), doc.artboards[0].rect, 1e-9));
// Art too large for the canvas at the scale asked for says so.
let o = ImportOptions { unit: Some(Unit::Meters), scale: Some(0.001), ..ImportOptions::default() };
let e = import(&entities(LINE), &o).unwrap_err();
assert!(e.contains("canvas"), "{e}");
let e = import(&entities(LINE), &ImportOptions { scale: Some(-1.0), ..ImportOptions::default() }).unwrap_err();
assert!(e.contains("scale"), "{e}");
}
#[test]
fn paper_layouts() {
let mut e = LINE.to_vec();
e.extend([(0, "CIRCLE"), (67, "1"), (10, "0"), (20, "0"), (40, "5")]);
let bytes = entities(&e);
assert_eq!(info(&bytes).unwrap().layouts, ["Model", "Layout1"]);
let model = leaves(&open(&bytes, &points()).document);
assert_eq!(model.len(), 1);
assert_eq!(bounds(&model[0]).width(), 100.0, "model space holds the line");
let paper = leaves(&open(&bytes, &ImportOptions { layout: Some("layout1".into()), ..points() }).document);
assert_eq!(bounds(&paper[0]).width(), 10.0, "the paper layout holds the circle");
let e = import(&bytes, &ImportOptions { layout: Some("Sheet".into()), ..points() }).unwrap_err();
assert!(e.contains("Model, Layout1"), "{e}");
}
#[test]
fn binary_dwg_damaged_and_foreign_files() {
let mut binary = BINARY_SENTINEL.to_vec();
binary.extend([0u8; 40]);
assert!(is_dxf(&binary));
let e = import(&binary, &ImportOptions::default()).unwrap_err();
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
let e = import(b"AC1032\x00\x00\x00", &ImportOptions::default()).unwrap_err();
assert!(e.contains("DWG"), "{e}");
for junk in [&b""[..], b"hello", b"<svg/>", b"999\nnote\n"] {
assert!(import(junk, &ImportOptions::default()).is_err(), "{junk:?}");
assert!(!is_dxf(junk), "{junk:?}");
}
assert!(is_dxf(b"999\nwritten by hand\n 0\nSECTION\n 2\nHEADER\n"));
// A file cut off in the middle keeps what came before.
let mut cut = entities(LINE);
cut.truncate(cut.len() - 30);
cut.extend(b"\nnot a code\n");
let r = open(&cut, &points());
assert_eq!(leaves(&r.document).len(), 1);
assert!(r.warnings.iter().any(|w| w.contains("damaged")), "{:?}", r.warnings);
// Hostile numbers are dropped; unknown entities are listed.
let e =
[(0, "CIRCLE"), (10, "1e999"), (20, "NaN"), (40, "1e300"), (0, "XLINE"), (10, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "1"), (21, "1")];
let r = open(&entities(&e), &points());
assert!(r.warnings.iter().any(|w| w.contains("1 XLINE")), "{:?}", r.warnings);
}
#[test]
fn drawings_the_export_writes_read_back() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let id = doc.alloc_id();
let rect = shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0));
let blue = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 255)), 2.0);
doc.insert(Some(layer), 0, Node::path(id, rect, blue)).unwrap();
let id = doc.alloc_id();
let circle = shapes::ellipse(Rect::new(140.0, 20.0, 180.0, 60.0));
doc.insert(Some(layer), 1, Node::path(id, circle, Appearance::basic(Paint::solid(Color::rgb8(255, 0, 0)), Paint::None, 0.0))).unwrap();
for version in [crate::DxfVersion::R12, crate::DxfVersion::R2000, crate::DxfVersion::R2018] {
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
let out = crate::export(&doc, &opts).unwrap();
let back = open(&out.bytes, &points());
let art = leaves(&back.document);
// Same place on the artboard (the export's origin is the artboard's bottom-left).
let shift = Affine::translate((0.0, 100.0 - height(&back.document)));
let all = art.iter().filter_map(|n| n.geometric_bounds()).map(|b| shift.transform_rect_bbox(b)).reduce(|a, b| a.union(b)).unwrap();
assert!(close(all, Rect::new(20.0, 20.0, 180.0, 70.0), 0.1), "{version:?}: {all:?}");
let stroked = art.iter().find(|n| n.appearance.stroke().is_some_and(|s| !s.paint.is_none())).unwrap();
assert_eq!(stroke_rgb(stroked), [0, 0, 255, 255], "{version:?}");
if version >= crate::DxfVersion::R2000 {
assert!(art.iter().any(|n| n.appearance.fill().is_some_and(|f| !f.paint.is_none())), "{version:?}: the fill comes back as a hatch");
let w = stroked.appearance.stroke().unwrap().width;
assert!((w - 2.0).abs() < 0.2, "{version:?}: lineweight {w}");
}
}
}
#[test]
fn info_lists_layers_layouts_and_version() {
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "0"), (62, "7"), (0, "LAYER"), (2, "Walls"), (62, "1"), (0, "ENDTAB")];
let i = info(&drawing(&[(9, "$ACADVER"), (1, "AC1015"), (9, "$INSUNITS"), (70, "6")], &tables, &[], LINE)).unwrap();
assert_eq!(i.version, "2000");
assert_eq!(i.units, "Meters");
assert_eq!((i.unit, i.scale), (Unit::Meters, 1.0));
assert_eq!(i.layers, ["0", "Walls"]);
assert_eq!(i.layouts, ["Model"]);
assert!(info(b"not a drawing").is_err());
}
#[test]
fn entities_extruded_along_minus_z_are_mirrored() {
let e = [(0, "CIRCLE"), (10, "50"), (20, "0"), (40, "10"), (210, "0"), (220, "0"), (230, "-1")];
let doc = open(&entities(&e), &points()).document;
let b = bounds(&leaves(&doc)[0]);
assert!((b.center().x + 50.0).abs() < 1e-6, "the centre's x is mirrored: {b:?}");
}
+157
View File
@@ -0,0 +1,157 @@
//! CAD text values as plain text: control codes (`%%d`), Unicode escapes (`\U+00B0`), caret
//! escapes (`^I`) and, in multiline text, the formatting codes (`\P` paragraph breaks, `{\fArial;…}`
//! font changes, `\S1/2;` stacked fractions…).
/// A single-line text value (TEXT, ATTRIB) as plain text.
pub(crate) fn plain(s: &str) -> String {
percent_codes(&escapes(s))
}
/// A multiline text value (MTEXT) as plain text with `\n` between paragraphs, and the font family
/// its first font change names.
pub(crate) fn mtext(s: &str) -> (String, Option<String>) {
let s = escapes(s);
let mut out = String::with_capacity(s.len());
let mut family = None;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' | '}' => {}
'\\' => match chars.next() {
Some('P' | 'X' | 'N') => out.push('\n'),
Some('~') => out.push('\u{A0}'),
Some(c @ ('\\' | '{' | '}')) => out.push(c),
Some('L' | 'l' | 'O' | 'o' | 'K' | 'k') => {}
Some('S') => {
// A stacked fraction `\Snum^den;` (or / or #): written as num/den.
let arg = until_semicolon(&mut chars);
out.push_str(&arg.replace(['^', '#'], "/"));
}
Some(f @ ('f' | 'F')) => {
let arg = until_semicolon(&mut chars);
if family.is_none() && f == 'f' {
family = arg.split('|').next().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
}
}
// Height, colour, tracking, oblique, width, alignment, paragraph: settings with
// an argument.
Some('H' | 'C' | 'c' | 'T' | 'Q' | 'W' | 'A' | 'p') => {
until_semicolon(&mut chars);
}
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
},
c => out.push(c),
}
}
(percent_codes(&out), family)
}
/// The characters up to the next `;` (dropped).
fn until_semicolon(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
let mut arg = String::new();
for c in chars.by_ref() {
if c == ';' {
break;
}
arg.push(c);
}
arg
}
/// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page)
/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped.
fn escapes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(c) = rest.chars().next() {
let after = rest.get(c.len_utf8()..).unwrap_or("");
if c == '\\'
&& let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4))
&& let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
{
out.push(ch);
rest = after.get(6..).unwrap_or("");
continue;
}
if c == '^'
&& let Some(next) = after.chars().next()
{
match next {
' ' => out.push('^'),
'I' => out.push('\t'),
_ => {}
}
rest = after.get(next.len_utf8()..).unwrap_or("");
continue;
}
if !c.is_control() || c == '\t' {
out.push(c);
}
rest = after;
}
out
}
/// `%%` control codes: `%%d` degree, `%%p` plus-minus, `%%c` diameter, `%%%` percent, `%%nnn` a
/// character code; the underline, overline and strike-through toggles (`%%u`, `%%o`, `%%k`) are
/// dropped.
fn percent_codes(s: &str) -> String {
if !s.contains("%%") {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find("%%") {
out.push_str(rest.get(..i).unwrap_or(""));
let code = rest.get(i + 2..).unwrap_or("");
let mut skip = 1;
match code.chars().next().map(|c| c.to_ascii_lowercase()) {
Some('d') => out.push('°'),
Some('p') => out.push('±'),
Some('c') => out.push('⌀'),
Some('%') => out.push('%'),
Some('u' | 'o' | 'k') => {}
Some(c) if c.is_ascii_digit() => {
let digits: String = code.chars().take(3).take_while(char::is_ascii_digit).collect();
skip = digits.len();
out.extend(digits.parse::<u32>().ok().and_then(char::from_u32).filter(|c| !c.is_control()));
}
_ => {
out.push_str("%%");
skip = 0;
}
}
rest = code.get(skip..).unwrap_or("");
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_codes_and_escapes() {
assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under");
assert_eq!(plain("caf\\U+00E9 ^ x^I^Jy"), "café ^x\ty", "`^ ` is a caret");
assert_eq!(plain("%%065%%066"), "AB");
assert_eq!(plain("50%% off"), "50%% off", "an unknown code stays");
assert_eq!(plain("\\U+"), "\\U+");
}
#[test]
fn mtext_formatting_is_dropped() {
let (t, family) = mtext("{\\fArial|b1|i0|c0|p34;Title}\\P\\H2.5x;Line \\C1;two\\~\\S1/2;\\Pa\\\\b {\\Lunder}");
assert_eq!(t, "Title\nLine two\u{A0}1/2\na\\b under");
assert_eq!(family.as_deref(), Some("Arial"));
// Carets are written "^ " (a caret before another character is a control character).
assert_eq!(mtext("x\\S3^ 4;").0, "x3/4");
assert_eq!(mtext("x\\S3#4;").0, "x3/4");
assert_eq!(mtext("trailing\\").0, "trailing\\");
}
}
+20 -1
View File
@@ -1,4 +1,4 @@
//! CAD interchange: a hand-written ASCII DXF writer, versions R12 to 2018.
//! CAD interchange: a hand-written ASCII DXF writer (versions R12 to 2018) and reader.
//!
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
@@ -10,11 +10,15 @@
//! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns,
//! clipping) is approximated or left out, and each such loss comes back as a warning.
//!
//! [`import()`] reads an ASCII DXF drawing (any version) into a document: see [`import`].
//!
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
//! - `writer`: group codes, handles and the file's sections, tables and objects.
//! - `scene`: the document walk that turns objects into entities.
//! - `import`: the reader, entities to art, blocks to symbols.
mod aci;
pub mod import;
mod scene;
mod writer;
@@ -22,6 +26,21 @@ use vectorcraft_doc::{Document, Unit};
use vectorcraft_geom::Rect;
pub use aci::{aci_rgb, nearest_aci};
pub use import::{DxfInfo, ImportOptions, Imported, import, info, is_dxf};
/// How deep symbols, blocks and brush art may nest in one another (deeper art is left out).
const MAX_NEST: u32 = 8;
/// Cap height as a fraction of the type size: the height of CAD text is that of its capitals.
const CAP_HEIGHT: f64 = 0.7;
/// The `$INSUNITS` codes of the units documents use (feet and inches write feet).
const INSUNITS: [(u8, Unit); 7] =
[(1, Unit::Inches), (2, Unit::Feet), (2, Unit::FeetInches), (4, Unit::Millimeters), (5, Unit::Centimeters), (6, Unit::Meters), (10, Unit::Yards)];
/// The `$INSUNITS` code of `unit` (none for points, picas and pixels).
fn insunits_code(unit: Unit) -> Option<u8> {
INSUNITS.iter().find(|(_, u)| *u == unit).map(|(c, _)| *c)
}
/// The DXF version a file is written for (the `$ACADVER` it declares).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+3 -15
View File
@@ -13,15 +13,11 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use crate::aci::nearest_aci;
use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed};
use crate::{ColorDepth, DxfImage, DxfOptions, DxfOutput, Preserve, RasterFormat};
use crate::{CAP_HEIGHT, ColorDepth, DxfImage, DxfOptions, DxfOutput, MAX_NEST, Preserve, RasterFormat};
/// Curves are flattened to within this distance (points) where DXF needs straight segments
/// (hatch boundaries, R12 polylines).
const FLATNESS: f64 = 0.05;
/// How deep symbols and brush art may nest in one another (deeper art is left out).
const MAX_NEST: u32 = 8;
/// Cap height as a fraction of the type size: the height of CAD text is that of its capitals.
const CAP_HEIGHT: f64 = 0.7;
/// Longest text value older DXF versions read.
const MAX_TEXT: usize = 250;
/// The lineweights DXF knows (hundredths of a millimetre).
@@ -227,15 +223,7 @@ impl<'a> Scene<'a> {
let r = self.o.region;
let limits = [0.0, 0.0, r.width() * self.k, r.height() * self.k];
let one = (self.o.scale - 1.0).abs() < 1e-12;
let insunits = match self.o.unit {
Unit::Inches if one => 1,
Unit::Feet | Unit::FeetInches if one => 2,
Unit::Millimeters if one => 4,
Unit::Centimeters if one => 5,
Unit::Meters if one => 6,
Unit::Yards if one => 10,
_ => 0,
};
let insunits = if one { crate::insunits_code(self.o.unit).unwrap_or(0) } else { 0 };
let drawing = Drawing {
version: self.o.version,
layers: self.layers,
@@ -328,7 +316,7 @@ impl<'a> Scene<'a> {
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(c, false);
}
+24
View File
@@ -892,6 +892,30 @@ impl Appearance {
g.transform(a);
}
}
/// Envelope Options → Distort Linear Gradients: bend the linear gradients of an object whose
/// geometric bounds are `bounds` with a warp, given its local affine approximation at a point
/// (taken at the middle of each gradient's vector). Unplaced ones are fixed to their fit first;
/// other gradients stay as they are.
pub fn warp_linear_gradients(&mut self, bounds: vectorcraft_geom::Rect, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) {
for i in &mut self.items {
let (g, b) = match i {
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => (g, bounds),
AppearanceItem::Stroke(s) => {
let b = s.paint_bounds(bounds);
let Paint::Gradient(g) = &mut s.paint else { continue };
(g, b)
}
_ => continue,
};
if g.gradient.kind != vectorcraft_color::GradientKind::Linear {
continue;
}
g.pin(b);
if let Some(geom) = g.geom {
g.transform(near(geom.start.midpoint(geom.end)));
}
}
}
/// Map placed gradients through a warp, given its local affine approximation at a point (taken
/// at each gradient's centre: the start of a radial, the middle of a linear vector). Freeform
/// points each follow the warp at their own position.
+57
View File
@@ -0,0 +1,57 @@
//! Asset Export (Window → Asset Export, Object → Collect for Export): pieces of art collected to
//! export on their own, at the export settings Export for Screens shares.
//!
//! An asset names objects rather than copying them: it follows edits (a moved object moves the
//! crop, a recoloured one exports recoloured) and lets go of objects that are deleted
//! ([`Document::prune_assets`]; an asset left with none goes too). Asset ids come from the
//! document's id counter, so they never repeat within a document.
use serde::{Deserialize, Serialize};
use crate::{Document, NodeId};
/// One asset: what it is called (its files' names) and the objects it exports, cropped to their
/// visual bounds.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ExportAsset {
pub id: u64,
pub name: String,
pub nodes: Vec<NodeId>,
}
/// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`…
const ASSET: &str = "Asset ";
impl Document {
/// Asset `id`.
pub fn asset(&self, id: u64) -> Option<&ExportAsset> {
self.assets.iter().find(|a| a.id == id)
}
/// The name of the next unnamed asset: one more than the highest `Asset N` so far.
pub fn next_asset_name(&self) -> String {
let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::<u64>().ok()).max().unwrap_or(0);
format!("{ASSET}{}", n.saturating_add(1))
}
/// Forget the objects of assets that are no longer in the document, and the assets left with
/// none (after every edit).
pub fn prune_assets(&mut self) {
if self.assets.iter().all(|a| a.nodes.iter().all(|id| self.node(*id).is_some())) {
return;
}
let mut assets = std::mem::take(&mut self.assets);
for a in &mut assets {
a.nodes.retain(|id| self.node(*id).is_some());
}
assets.retain(|a| !a.nodes.is_empty());
self.assets = assets;
}
/// `ids` that are in the document, back to front.
pub fn paint_order(&self, ids: impl IntoIterator<Item = NodeId>) -> Vec<NodeId> {
let mut v: Vec<(Vec<usize>, NodeId)> = ids.into_iter().filter_map(|id| self.index_path(id).map(|p| (p, id))).collect();
v.sort();
v.into_iter().map(|(_, id)| id).collect()
}
}
File diff suppressed because it is too large. Load diff
+66
View File
@@ -0,0 +1,66 @@
//! Clipped art read from page descriptions (metafiles, PostScript) into clipping groups.
//!
//! Importers record each object they draw with the chain of clips in force at the time, outermost
//! first ([`Drawn`]); [`nest`] then puts runs of objects under the same clip into one clipping
//! group, nesting groups for nested clips, keeping the drawing order.
use std::sync::Arc;
use vectorcraft_geom::{BezPath, FillRule, PathData};
use crate::appearance::Appearance;
use crate::{Document, Node, NodeKind};
/// A clip: a region in document space and its rule. Each has an id unique within an import, so
/// what one clip clips can be grouped.
#[derive(Debug)]
pub struct Clip {
pub id: u32,
pub path: BezPath,
pub rule: FillRule,
}
/// Objects in drawing order, each with its clips (outermost first).
pub type Drawn = Vec<(Vec<Arc<Clip>>, Node)>;
/// The nodes of `items`, those under clips grouped into clipping groups (ids from `doc`), in
/// order.
pub fn nest(doc: &mut Document, items: Drawn) -> Vec<Arc<Node>> {
nest_at(doc, items, 0)
}
/// [`nest`] for the clips at `depth` and deeper.
fn nest_at(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
let mut out: Vec<Arc<Node>> = vec![];
let mut group: Drawn = vec![];
let mut clip: Option<Arc<Clip>> = None;
for (chain, node) in items {
let here = chain.get(depth).cloned();
if here.as_ref().map(|c| c.id) != clip.as_ref().map(|c| c.id) {
if let Some(c) = clip.take() {
out.push(clip_group(doc, &c, std::mem::take(&mut group), depth));
}
clip = here.clone();
}
match here {
Some(_) => group.push((chain, node)),
None => out.push(Arc::new(node)),
}
}
if let Some(c) = clip {
out.push(clip_group(doc, &c, group, depth));
}
out
}
/// A clipping group of `items`, clipped by `c` (their clip at `depth`).
fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<Node> {
let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(&c.path), Appearance::default());
if let NodeKind::Path { clipping, rule, .. } = &mut path.kind {
*clipping = true;
*rule = c.rule;
}
let mut children = vec![Arc::new(path)];
children.extend(nest_at(doc, items, depth + 1));
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: true }))
}
+23 -6
View File
@@ -20,15 +20,18 @@ pub struct Hit {
pub leaf: NodeId,
pub ancestry: Vec<NodeId>,
pub kind: HitKind,
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
/// its content, not the envelope, is the object clicked.
pub contents_of: Option<NodeId>,
}
impl Hit {
/// The object the Selection tool selects: the child of the layer (the outermost group).
/// Inside isolation mode (`scope`), the child of the isolated container instead.
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
if let Some(s) = scope
&& let Some(i) = self.ancestry.iter().position(|x| *x == s)
{
// The innermost of the isolated container and an envelope whose contents are edited.
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
if let Some(i) = at(scope).max(at(self.contents_of)) {
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
}
// Skip layers and sublayers.
@@ -68,7 +71,8 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
continue;
}
chain.push(layer.id);
if let Some(h) = hit_children(layer, p, opt, &mut chain) {
if let Some(mut h) = hit_children(layer, p, opt, &mut chain) {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
return Some(h);
}
chain.pop();
@@ -76,6 +80,11 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
None
}
/// Is `n` an envelope whose contents are being edited (they hit, not the envelope)?
fn edits_contents(n: &Node) -> bool {
matches!(n.kind, NodeKind::Envelope { editing: true, .. })
}
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
/// glyph outlines need the font engine, above this crate.
fn clip_contains(clip: &Node, p: Point) -> bool {
@@ -99,7 +108,9 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
if !c.visible || c.locked {
continue;
}
if let Some(b) = c.reach_bounds()
// (An envelope's content sits where it was, not where the envelope draws it.)
if !edits_contents(c)
&& let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
continue;
@@ -107,7 +118,13 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
chain.push(c.id);
let hit = match &c.kind {
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }),
// Edit Contents: the envelope's content hits, undistorted.
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain),
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
// as the blend.
NodeKind::Blend { .. } => hit_children(c, p, opt, chain)
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }),
};
if hit.is_some() {
return hit;
+157
View File
@@ -0,0 +1,157 @@
//! Printing inks: the plates of a document (four process inks and its spot inks), separating a
//! colour into them ([`inks`]), and visiting every colour of the art with its swatch link (the
//! Separations Preview recolours the art with these; print separates it into plates).
use std::sync::Arc;
use vectorcraft_color::cms::{Cms, Intent, PROCESS_PLATES};
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_color::{Color, Paint};
use crate::{AppearanceItem, Document, Node, NodeKind};
/// Ink coverage of one paint colour.
#[derive(Clone, Debug, PartialEq)]
pub struct Inks {
/// Process inks C, M, Y, K (0..1).
pub cmyk: [f32; 4],
/// Spot ink (swatch name, tint 0..1).
pub spot: Option<(String, f32)>,
/// Registration: the tint (every process ink's) prints on every spot plate too.
pub registration: bool,
}
impl Inks {
/// The tint this colour prints on spot plate `name`.
pub fn spot_tint(&self, name: &str) -> f32 {
match &self.spot {
_ if self.registration => self.cmyk[0],
Some((n, t)) if n == name => *t,
_ => 0.0,
}
}
/// The ink this colour prints on `plate` (a process plate name or a spot swatch name).
pub fn on_plate(&self, plate: &str) -> f32 {
match PROCESS_PLATES.iter().position(|p| *p == plate) {
Some(i) => self.cmyk[i],
None => self.spot_tint(plate),
}
}
}
/// A printing plate.
#[derive(Clone, Debug, PartialEq)]
pub struct Plate {
pub name: String,
pub spot: bool,
/// Display colour of the ink (for the panel swatch).
pub rgb: [f32; 3],
}
/// The plates of `doc`: the four process plates plus one per spot swatch.
pub fn plates(doc: &Document) -> Vec<Plate> {
let c = vectorcraft_color::cms::active();
let mut v: Vec<Plate> = PROCESS_PLATES
.iter()
.enumerate()
.map(|(i, n)| {
let mut ink = [0.0; 4];
ink[i] = 1.0;
Plate { name: (*n).into(), spot: false, rgb: c.cmyk_to_srgb(ink, false) }
})
.collect();
for sw in doc.swatches_iter() {
if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint)
&& !v.iter().any(|p| p.name == sw.name)
{
v.push(Plate { name: sw.name.clone(), spot: true, rgb: c.display_rgb(&doc.linked_color(*color, true)) });
}
}
v
}
/// The ink colour of spot swatch `name` as it shows ([`Document::linked_color`]).
pub fn spot_color(doc: &Document, name: &str) -> Option<Color> {
let s = doc.swatch(name).filter(|s| s.spot)?;
s.paint.color().map(|c| doc.linked_color(c, true))
}
/// A colour's swatch link and tint (`(swatch name, tint 0..1)`), as the colour visitors pass it.
pub type Link<'a> = Option<(&'a str, f32)>;
/// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at
/// its tint; one linked to the Registration swatch prints its tint on every plate.
pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks {
if let Some((REGISTRATION, tint)) = link {
return Inks { cmyk: [tint.clamp(0.0, 1.0); 4], spot: None, registration: true };
}
if let Some((name, tint)) = link
&& doc.swatch(name).is_some_and(|s| s.spot && s.paint.color().is_some())
{
return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))), registration: false };
}
Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false }
}
/// Visits one colour: the colour, its swatch link and its tint, all of which it may change.
pub type ColorVisitor<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) + 'a;
fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) {
match p {
Paint::Solid { color, swatch, tint } => f(color, swatch, tint),
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
f(&mut s.color, &mut s.swatch, &mut s.tint);
}
}
_ => {}
}
}
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, not
/// opacity masks) with its swatch link and tint. Mesh points have no link: changes to it are
/// dropped.
pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) => visit_paint(&mut l.paint, f),
AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f),
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
visit_paint(&mut r.style.fill, f);
visit_paint(&mut r.style.stroke, f);
}
}
NodeKind::Mesh(m) => {
for p in &mut m.points {
f(&mut p.color, &mut None, &mut 1.0);
}
}
_ => {}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
visit_node_colors(Arc::make_mut(c), f);
}
}
}
/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh
/// points) given its swatch link and tint, keeping the links.
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) {
visit_node_colors(n, &mut |c, swatch, tint| *c = f(c, swatch.as_deref().map(|s| (s, *tint))));
}
/// Apply `f` to every colour in the document's art and symbol definitions.
pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) -> Color) {
for l in &mut doc.layers {
map_node_colors(Arc::make_mut(l), f);
}
for s in &mut doc.symbols {
map_node_colors(Arc::make_mut(&mut s.art), f);
}
}
+76 -20
View File
@@ -6,19 +6,28 @@
#![forbid(unsafe_code)]
pub mod appearance;
pub mod assets;
pub mod blend;
pub mod clipnest;
pub mod graph;
pub mod hit;
pub mod inks;
pub mod links;
pub mod live;
pub mod marks;
pub mod metadata;
pub mod node;
pub mod orient;
pub mod overprint;
pub mod pattern;
pub mod perspective;
mod pixels;
pub mod profiles;
pub mod puppet;
pub mod range;
pub mod rastersettings;
mod reach;
pub mod recolor;
pub mod selection;
pub mod setup;
pub mod slices;
@@ -47,6 +56,7 @@ pub use appearance::{
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign,
StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use graph::{GraphKind, GraphSpec};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
@@ -56,8 +66,11 @@ pub use node::Knockout;
pub use node::Scaling;
pub use node::{ImageMap, ObjectAttributes};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use orient::OrientedBox;
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use perspective::PerspectiveAttachment;
pub use profiles::ColorProfiles;
pub use puppet::{PuppetPin, PuppetPins};
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
pub use selection::{AnchorRef, Selection};
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
@@ -170,8 +183,11 @@ impl Unit {
if s == "-0" { "0".into() } else { s.into() }
}
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
///
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
/// math typed before a field's unit suffix works.
pub fn parse(self, s: &str) -> Option<f64> {
parse_measure(s, self)
parse_measure(s, self).filter(|v| v.is_finite())
}
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
/// `feetInches`).
@@ -214,14 +230,20 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
if l.trim().is_empty() {
continue;
}
let a = parse_measure(l, default)?;
return match op {
'+' => Some(a + parse_measure(r, default)?),
'-' => Some(a - parse_measure(r, default)?),
'*' => Some(a * r.trim().parse::<f64>().ok()?),
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
_ => {
let d = r.trim().parse::<f64>().ok()?;
if d == 0.0 { None } else { Some(a / d) }
// A factor or divisor is a plain number; a unit after it is the expression's.
let (k, unit) = number_unit(r)?;
let a = parse_measure(l, unit.unwrap_or(default))?;
if op == '*' {
Some(a * k)
} else if k == 0.0 {
None
} else {
Some(a / k)
}
}
};
}
@@ -235,22 +257,40 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
{
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
}
let (v, unit) = number_unit(s)?;
Some(unit.unwrap_or(default).to_pt(v))
}
/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit).
fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
let s = s.trim();
let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
let v: f64 = s[..num_end].trim().parse().ok()?;
let unit = match s[num_end..].trim() {
"" => default,
"pt" => Unit::Points,
"px" => Unit::Pixels,
"in" | "\"" => Unit::Inches,
"mm" => Unit::Millimeters,
"cm" => Unit::Centimeters,
"m" => Unit::Meters,
"ft" | "'" => Unit::Feet,
"yd" => Unit::Yards,
"pc" => Unit::Picas,
let v: f64 = s.get(..num_end)?.trim().parse().ok()?;
let unit = match s.get(num_end..)?.trim() {
"" => None,
"pt" => Some(Unit::Points),
"px" => Some(Unit::Pixels),
"in" | "\"" => Some(Unit::Inches),
"mm" => Some(Unit::Millimeters),
"cm" => Some(Unit::Centimeters),
"m" => Some(Unit::Meters),
"ft" | "'" => Some(Unit::Feet),
"yd" => Some(Unit::Yards),
"p" | "pc" => Some(Unit::Picas),
_ => return None,
};
Some(unit.to_pt(v))
Some((v, unit))
}
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
pub fn parse_number(s: &str) -> Option<f64> {
if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) {
return None;
}
// A leading `+` is a sign (`+5`), as a plain number read it.
let s = s.trim();
Unit::Points.parse(s.strip_prefix('+').unwrap_or(s))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -520,6 +560,18 @@ pub struct Document {
/// them (on, the default); off, auto slices cover the art and the slices.
#[serde(default = "yes", skip_serializing_if = "skip::is_true")]
pub slices_clip_to_artboard: bool,
/// File → Print: the print settings saved with the document (`vectorcraft_pdf::PrintSettings`
/// as JSON: the print engine sits above this crate); `None` until they are set up.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub print_setup: Option<serde_json::Value>,
/// Window → Asset Export: art collected for export ([`ExportAsset`]), in panel order. Their
/// export settings are Export for Screens' ([`Document::export_settings`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<ExportAsset>,
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
/// state, never saved.
#[serde(skip)]
pub puppet: Option<Arc<PuppetPins>>,
}
fn ppi72() -> f64 {
@@ -585,6 +637,9 @@ impl Document {
extra: Default::default(),
slices: vec![],
slices_clip_to_artboard: true,
print_setup: None,
assets: vec![],
puppet: None,
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
@@ -607,6 +662,7 @@ impl Document {
l.walk(&mut |n| max = max.max(n.id.0));
}
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
self.next_id = self.next_id.max(max + 1);
}
+603 -428
View File
File diff suppressed because it is too large. Load diff
+35 -5
View File
@@ -272,6 +272,14 @@ pub enum NodeKind {
/// Edit Contents mode (the content, not the envelope, is edited).
#[serde(default)]
editing: bool,
/// Envelope Options besides Fidelity.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
options: crate::live::EnvelopeOptions,
/// The envelope's own axes (→ the document): the transforms it took since it was made that
/// the page axes can't stand for (rotation, shear, reflection). The content maps from its
/// bounds in this frame, and a warp bends along it.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
frame: Affine,
},
/// Gradient mesh.
Mesh(GradientMesh),
@@ -335,6 +343,14 @@ pub struct Node {
/// these options.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slice: Option<Box<crate::SliceOptions>>,
/// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its
/// bounding box and handles stand at this angle. Transforms turn it with the object (see
/// [`crate::orient`]); Reset Bounding Box sets it back to 0.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub bbox_angle: f64,
/// Object › Perspective: the perspective grid plane the object is attached to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
}
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
@@ -383,6 +399,8 @@ impl Node {
graphic_style: None,
attrs: None,
slice: None,
bbox_angle: 0.0,
perspective: None,
}
}
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
@@ -516,14 +534,14 @@ impl Node {
.iter()
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
NodeKind::Text(t) => t.bounds(),
NodeKind::Text(t) => self.projected(t.bounds()),
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
NodeKind::SymbolInstance { xf, .. } => Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0))),
NodeKind::SymbolInstance { xf, .. } => self.projected(Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0)))),
NodeKind::Blend { children, spec } => {
let b = crate::live::nodes_bounds(children);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
}
NodeKind::Envelope { content, kind, .. } => crate::live::envelope_bounds(content, kind),
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame),
NodeKind::Mesh(m) => m.bounds(),
NodeKind::Repeat(r) => r.bounds(),
}
@@ -572,6 +590,11 @@ impl Node {
self.transform_scaled(a, &scaling.into());
}
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
// Type and symbols in perspective keep looking the same, moved by `a`.
self.transform_projection(a);
// Refitting an unplaced gradient only reproduces moves and uniform scales.
if !keeps_gradient_fit(a) {
self.pin_gradients();
@@ -623,15 +646,22 @@ impl Node {
s.transform(a);
}
}
NodeKind::Envelope { content, kind, .. } => {
NodeKind::Envelope { content, kind, frame, .. } => {
for c in content.iter_mut() {
Arc::make_mut(c).transform_scaled(a, sc);
}
*frame = crate::live::envelope_frame(a * *frame);
match kind {
EnvelopeKind::Mesh { points, .. } => {
EnvelopeKind::Mesh { points, handles, .. } => {
for p in points.iter_mut() {
*p = a * *p;
}
// Handles are offsets: they take the linear part.
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]);
for h in handles.iter_mut().flatten() {
*h = (lin * h.to_point()).to_vec2();
}
}
EnvelopeKind::TopObject { path } => path.transform(a),
EnvelopeKind::Warp { .. } => {}
+195
View File
@@ -0,0 +1,195 @@
//! Rotated bounding boxes. Every object keeps the angle of its own axes ([`Node::bbox_angle`])
//! through the transforms applied to it, so after a rotation its bounding box and handles stay
//! square to it: scaling by a side handle stretches it along its own width or height. Path
//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0.
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
use crate::{Document, Node, NodeId, NodeKind};
/// Angles closer than this (degrees) are the same.
const EPS_DEG: f64 = 1e-6;
/// A bounding box square to a frame turned by `angle`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OrientedBox {
/// Counter-clockwise degrees in (-180, 180]; 0: square to the page.
pub angle: f64,
/// The box in the turned frame: [`OrientedBox::to_doc`] places it on the page.
pub rect: Rect,
}
impl OrientedBox {
/// A box square to the page.
pub fn aligned(rect: Rect) -> Self {
Self { angle: 0.0, rect }
}
/// The turned frame → the page (a rotation about the page origin).
pub fn to_doc(&self) -> Affine {
if self.angle == 0.0 { Affine::IDENTITY } else { Affine::rotate(-self.angle.to_radians()) }
}
/// A page point in the turned frame.
pub fn to_local(&self, p: Point) -> Point {
self.to_doc().inverse() * p
}
/// `a`, a transform of the turned frame, as a page transform.
pub fn conjugate(&self, a: Affine) -> Affine {
if self.angle == 0.0 { a } else { self.to_doc() * a * self.to_doc().inverse() }
}
pub fn center(&self) -> Point {
self.to_doc() * self.rect.center()
}
/// One of the 9 reference points (row-major from the box's own top-left), on the page.
pub fn reference_point(&self, index: usize) -> Point {
self.to_doc() * vectorcraft_geom::reference_point(self.rect, index)
}
/// The corners on the page: top-left, top-right, bottom-right, bottom-left of the box itself.
pub fn corners(&self) -> [Point; 4] {
let r = self.rect;
[(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.to_doc() * Point::new(x, y))
}
}
/// The angle (counter-clockwise degrees) of an object's axes after `a`: where `a` takes its x axis.
/// A mirroring `a` allows two boxes 180° apart: the one nearer the old angle (then the smaller
/// angle) is kept, so flipping an upright object keeps it upright.
pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
if (m0, m1, m2, m3) == (1.0, 0.0, 0.0, 1.0) {
return angle;
}
let (s, c) = (-angle.to_radians()).sin_cos();
let v = Vec2::new(m0 * c + m2 * s, m1 * c + m3 * s);
// Degenerate (or non-finite) transforms leave the angle alone.
let len = v.hypot();
if !len.is_finite() || len <= 1e-12 {
return angle;
}
let mut out = normalize_deg(-v.y.atan2(v.x).to_degrees());
let dist = |x: f64| normalize_deg(x - angle).abs();
if a.determinant() < 0.0 {
let alt = normalize_deg(out + 180.0);
let (d0, d1) = (dist(out), dist(alt));
if d1 < d0 - EPS_DEG || ((d1 - d0).abs() <= EPS_DEG && alt.abs() < out.abs()) {
out = alt;
}
}
// Moves and scales along the object's own axes keep its angle exactly.
if dist(out) <= EPS_DEG {
angle
} else if out.abs() <= EPS_DEG {
0.0
} else {
out
}
}
impl Node {
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
/// without changing it.
pub fn bounds_in(&self, a: Affine, visual: bool) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: false } => {
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual)));
// The group's own strokes paint around its members (as in `visual_bounds`).
let o = if visual { self.appearance.outset() } else { 0.0 };
b.map(|b| b.inflate(o, o))
}
NodeKind::Group { children, clip: true } => {
let clip = children.first()?;
let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_));
clip.bounds_in(a, visual && stroked)
}
_ => {
let mut n = self.clone();
n.transform(a, false);
if visual { n.visual_bounds() } else { n.geometric_bounds() }
}
}
}
}
impl Document {
/// The angle the bounding box of `ids` stands at: the `bbox_angle` they share, else 0.
pub fn bbox_angle(&self, ids: &[NodeId]) -> f64 {
let mut angles = ids.iter().filter_map(|id| self.node(*id)).map(|n| n.bbox_angle);
let Some(first) = angles.next() else { return 0.0 };
if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 }
}
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]):
/// geometric bounds, or `visual` with their strokes.
pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option<OrientedBox> {
let angle = self.bbox_angle(ids);
if angle == 0.0 {
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
}
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?;
Some(OrientedBox { angle, rect })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Appearance;
use vectorcraft_geom::shapes;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn rot(deg: f64) -> Affine {
Affine::rotate(-deg.to_radians())
}
#[test]
fn rotations_add_and_moves_keep_the_angle() {
assert!(close(transformed_angle(0.0, rot(45.0)), 45.0));
assert!(close(transformed_angle(170.0, rot(20.0)), -170.0));
assert_eq!(transformed_angle(30.0, Affine::translate((5.0, 7.0))), 30.0);
assert_eq!(transformed_angle(45.0, rot(-45.0)), 0.0);
}
#[test]
fn local_scales_keep_the_angle_exactly() {
let b = OrientedBox { angle: 45.0, rect: Rect::ZERO };
assert_eq!(transformed_angle(45.0, b.conjugate(Affine::scale_non_uniform(3.0, 0.5))), 45.0);
// A flip along the object's own axis keeps the box too.
let b = OrientedBox { angle: 135.0, rect: Rect::ZERO };
assert_eq!(transformed_angle(135.0, b.conjugate(Affine::scale_non_uniform(-1.0, 1.0))), 135.0);
}
#[test]
fn page_flips_mirror_the_angle() {
let flip_h = Affine::scale_non_uniform(-1.0, 1.0);
let flip_v = Affine::scale_non_uniform(1.0, -1.0);
assert_eq!(transformed_angle(0.0, flip_h), 0.0);
assert_eq!(transformed_angle(0.0, flip_v), 0.0);
assert!(close(transformed_angle(30.0, flip_h), -30.0));
assert!(close(transformed_angle(30.0, flip_v), -30.0));
// Degenerate transforms leave it alone.
assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0);
}
#[test]
fn oriented_bounds_hug_a_rotated_rectangle() {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::default_art())).unwrap();
let c = Point::new(50.0, 25.0);
d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false);
let b = d.oriented_bounds(&[id], false).unwrap();
assert!(close(b.angle, 30.0));
assert!(close(b.rect.width(), 100.0) && close(b.rect.height(), 50.0));
assert!(b.center().distance(c) < 1e-9);
// The page box is bigger.
assert!(d.bounds_of(&[id], false).unwrap().width() > 100.0);
// Grouped: the group has no angle of its own, the bounds are square to the page.
let g = d.alloc_id();
d.insert(Some(l), 1, Node::group(g, vec![])).unwrap();
d.move_node(id, Some(g), 0).unwrap();
assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0);
}
}
+64 -1
View File
@@ -2,7 +2,9 @@
//! over the inks below instead of knocking them out ([`crate::FillLayer::overprint`]).
//! Edit → Edit Colors → Overprint Black sets it on black fills and strokes ([`OverprintBlack`]).
use vectorcraft_color::{Color, Paint};
use std::sync::Arc;
use vectorcraft_color::{BlendMode, Color, Paint};
use crate::{Document, Node, NodeId, NodeKind};
@@ -92,6 +94,40 @@ impl Node {
}
}
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend
/// mode of their own keep it), copying only the nodes on the way to them: on screen and on a
/// printed plate this approximates their inks printing over the inks below. With `discard_white`
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
if !a.has_overprint() {
return;
}
let n = Arc::make_mut(a);
let multiplies =
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
for it in &mut n.appearance.items {
match it {
crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
_ => {}
}
}
for c in n.children_mut().into_iter().flatten() {
multiply_overprints(c, discard_white);
}
}
/// A solid paint of paper white (no ink in any colour model).
pub fn is_white(p: &Paint) -> bool {
let Paint::Solid { color, .. } = p else { return false };
match *color {
Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0,
Color::Cmyk { c, m, y, k } => c.max(m).max(y).max(k) <= 0.0,
Color::Gray { k } => k <= 0.0,
Color::Lab { l, a, b } => l >= 100.0 && a == 0.0 && b == 0.0,
}
}
impl Document {
/// The names of the spot swatches.
pub fn spot_names(&self) -> Vec<String> {
@@ -113,6 +149,33 @@ impl Document {
}
}
/// Whether any fill or stroke of `n`'s subtree overprints, characters' too.
fn overprints_anywhere(n: &Node) -> bool {
n.appearance.items.iter().any(|i| i.overprint())
|| matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke))
|| n.children().is_some_and(|c| c.iter().any(|c| overprints_anywhere(c)))
}
/// Stop every fill and stroke of `a`'s subtree overprinting (characters' too), copying only the
/// nodes on the way to them.
pub fn clear_overprints(a: &mut Arc<Node>) {
if !overprints_anywhere(a) {
return;
}
let n = Arc::make_mut(a);
for it in &mut n.appearance.items {
*it.overprint_mut() = false;
}
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
(r.style.overprint_fill, r.style.overprint_stroke) = (false, false);
}
}
for c in n.children_mut().into_iter().flatten() {
clear_overprints(c);
}
}
#[cfg(test)]
mod tests {
use super::*;
+69
View File
@@ -0,0 +1,69 @@
//! Perspective Grid attachments (Object › Perspective).
//!
//! The grid itself is document data (`Document.unknown["perspectiveGrid"]`, modelled by the tools
//! crate). Which plane an object lies on is kept on the object, so copies, duplicates and pastes
//! stay attached and deleted objects leave nothing stale behind.
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Homography, Rect};
use crate::{Node, NodeKind};
/// The plane an object in perspective lies on.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PerspectiveAttachment {
/// `left`, `right` or `ground`.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub plane: String,
/// Where along the plane's normal (points) the parallel plane the object lies on is: 0 is the
/// grid plane in its original place.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub depth: f64,
/// Type and symbol instances: the projective map (row-major 3 × 3) that draws their flat art
/// in perspective. They stay type and symbol instances; the renderer and exporters project
/// their outlines.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection: Option<[f64; 9]>,
/// Object › Perspective › Edit Text in progress: the type is drawn flat to be edited (not
/// saved).
#[serde(skip)]
pub editing: bool,
}
impl PerspectiveAttachment {
pub fn new(plane: &str, depth: f64) -> Self {
Self { plane: plane.to_string(), depth: if depth.is_finite() { depth } else { 0.0 }, ..Self::default() }
}
/// The projection, when it is a valid one.
pub fn homography(&self) -> Option<Homography> {
self.projection.and_then(Homography::from_array)
}
}
impl Node {
/// The projective map type or a symbol instance in perspective is drawn through (not while
/// Edit Text shows it flat).
pub fn projection(&self) -> Option<Homography> {
let a = self.perspective.as_deref().filter(|a| !a.editing)?;
matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten()
}
/// `b` (flat bounds) as drawn: the box of its projection for type and symbols in perspective.
pub(crate) fn projected(&self, b: Option<Rect>) -> Option<Rect> {
match (self.projection(), b) {
(Some(h), Some(b)) => h.map_rect_bbox(b).or(Some(b)),
(_, b) => b,
}
}
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too).
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
if let Some(p) = self.perspective.as_deref_mut()
&& let Some(h) = p.homography().and_then(|h| h.conjugated(a))
{
p.projection = Some(h.to_array());
}
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Puppet Warp pins: editing state the Puppet Warp tool and `object.puppetWarp` share while the
//! same artwork stays selected. It lives in the document (never saved) so Undo and Redo take the
//! pins back with the warps they made.
use std::sync::Arc;
use vectorcraft_geom::Point;
use crate::{Document, Node, NodeId};
/// One pin: where it sits on the rest shape, where it is now, and the turn it holds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PuppetPin {
pub rest: Point,
pub at: Point,
/// Radians the art is turned around the pin; `None`: free to turn.
pub angle: Option<f64>,
}
/// The pins on some objects and the shape they warp from.
#[derive(Clone, Debug, PartialEq)]
pub struct PuppetPins {
/// The warped objects.
pub ids: Vec<NodeId>,
/// Their rest shape: the objects as they were when pinning began (the warp always starts
/// from it, so warps don't stack and dragging a pin back restores the original).
pub rest: Vec<Arc<Node>>,
/// The objects as the last warp left them: once anything else edits them, the pins start
/// again from the objects as they are.
pub result: Vec<Arc<Node>>,
pub pins: Vec<PuppetPin>,
}
impl PuppetPins {
/// The pins are still those of `ids` in `doc`: the same objects, as the last warp left them.
pub fn holds(&self, doc: &Document, ids: &[NodeId]) -> bool {
self.ids == ids
&& self.ids.len() == self.result.len()
&& ids.iter().zip(&self.result).all(|(id, r)| doc.node_arc(*id).is_some_and(|a| Arc::ptr_eq(a, r)))
}
}
impl Document {
/// The node `id` as the document holds it (shared: comparing pointers says whether it changed).
pub fn node_arc(&self, id: NodeId) -> Option<&Arc<Node>> {
fn find(nodes: &[Arc<Node>], id: NodeId) -> Option<&Arc<Node>> {
for n in nodes {
if n.id == id {
return Some(n);
}
if let Some(f) = n.children().and_then(|ch| find(ch, id)) {
return Some(f);
}
}
None
}
find(&self.layers, id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Appearance;
use vectorcraft_geom::{Rect, shapes};
#[test]
fn pins_hold_until_their_objects_change_and_are_never_saved() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
let node = d.node_arc(id).cloned().unwrap();
let pin = PuppetPin { rest: Point::new(5.0, 5.0), at: Point::new(6.0, 5.0), angle: Some(0.5) };
d.puppet = Some(Arc::new(PuppetPins { ids: vec![id], rest: vec![node.clone()], result: vec![node], pins: vec![pin] }));
let pins = d.puppet.clone().unwrap();
assert!(pins.holds(&d, &[id]));
assert!(!pins.holds(&d, &[id, l]), "other objects");
// Saved and read back without them (editing state).
let json = serde_json::to_string(&d).unwrap();
assert!(!json.contains("puppet"));
let back: Document = serde_json::from_str(&json).unwrap();
assert!(back.puppet.is_none());
assert_eq!(back.node(id), d.node(id));
// An edit by anything else: the pins no longer hold.
d.node_mut(id).unwrap().name = Some("moved".into());
assert!(!pins.holds(&d, &[id]));
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Page ranges as people type them (`"1-3, 5"`): artboards to export or print, PDF pages to
//! open, tiles to print.
/// A 1-based page/artboard range such as `"1-3, 5"` → 0-based indices in the order given (repeats
/// dropped). Each number must lie in `1..=count`; `"3-"` runs to the last one, `"-2"` from the first.
pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
let num = |t: &str, open: usize| -> Result<usize, String> {
let t = t.trim();
if t.is_empty() {
return Ok(open);
}
t.parse().ok().filter(|n| (1..=count).contains(n)).ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
};
let mut out = vec![];
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.replace('\u{2013}', "-").split_once('-') {
Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")),
Some((a, b)) => (num(a, 1)?, num(b, count)?),
None => {
let n = num(part, 0)?;
(n, n)
}
};
if a > b {
return Err(format!("range `{s}`: `{part}` runs backwards"));
}
for i in a - 1..b {
if !out.contains(&i) {
out.push(i);
}
}
}
if out.is_empty() {
return Err(format!("range `{s}` is empty"));
}
Ok(out)
}
+111
View File
@@ -0,0 +1,111 @@
//! The colour visitor that recolouring shares: Edit Colors, Recolor Artwork and the live colour
//! adjustment effects walk an object's colours through [`recolor_node`] and decide what each
//! colour, mesh point and embedded image becomes through a [`ColorVisitor`].
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use crate::{AppearanceItem, Node, NodeKind};
/// What a recolouring pass does to the colours it meets.
pub trait ColorVisitor {
/// A fill's or stroke's paint (an object's, or a text run's): true when it changed.
fn paint(&mut self, p: &mut Paint) -> bool;
/// One gradient-mesh point's colour.
fn color(&mut self, c: Color) -> Color;
/// The key of embedded image `key` recoloured, `None` when it stays as it is.
fn image(&mut self, key: &str) -> Option<String>;
}
/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of
/// embedded images.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Reach {
pub fill: bool,
pub stroke: bool,
pub images: bool,
}
impl Reach {
/// Every colour.
pub const ALL: Reach = Reach { fill: true, stroke: true, images: true };
}
/// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of
/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay
/// as they are). Members that change are replaced by copies; the others stay shared. Returns the
/// number of paints, meshes and images that changed.
pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize {
let mut changed = 0;
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize,
AppearanceItem::Stroke(l) if reach.stroke => changed += v.paint(&mut l.paint) as usize,
_ => {}
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
if reach.fill {
changed += v.paint(&mut r.style.fill) as usize;
}
if reach.stroke {
changed += v.paint(&mut r.style.stroke) as usize;
}
}
}
NodeKind::Mesh(m) if reach.fill => {
let mut ch = false;
for p in &mut m.points {
let c = v.color(p.color);
ch |= c != p.color;
p.color = c;
}
changed += ch as usize;
}
NodeKind::Image(im) if reach.images && im.link.is_none() => {
if let Some(k) = v.image(&im.key) {
im.key = k;
changed += 1;
}
}
_ => {
for c in n.children_mut().into_iter().flatten() {
let mut m = (**c).clone();
let k = recolor_node(&mut m, reach, v);
if k > 0 {
*c = Arc::new(m);
changed += k;
}
}
}
}
changed
}
/// Apply `f` to the solid colour or gradient stops of a paint, with their swatch link and tint
/// ([`Paint::map_links`]); a gradient that changes is no longer its gradient swatch's. Returns
/// whether anything changed.
pub fn map_links(p: &mut Paint, f: &dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
let changed = p.map_links(&mut |c, l, t| f(c, l, t));
if changed && let Paint::Gradient(g) = p {
g.swatch = None;
}
changed
}
/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether
/// anything changed.
pub fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
map_links(p, &|c, link, _| {
let n = f(*c);
if n == *c {
return false;
}
*c = n;
*link = None;
true
})
}
+1 -3
View File
@@ -117,9 +117,7 @@ impl Selection {
}
/// Top-level ordering: selected ids sorted by paint order (bottom first).
pub fn in_paint_order(&self, doc: &Document) -> Vec<NodeId> {
let mut v: Vec<(Vec<usize>, NodeId)> = self.objects.iter().filter_map(|id| doc.index_path(*id).map(|p| (p, *id))).collect();
v.sort();
v.into_iter().map(|(_, id)| id).collect()
doc.paint_order(self.objects.iter().copied())
}
}
+4
View File
@@ -414,6 +414,9 @@ pub enum TextKind {
/// A text object. `runs` split into paragraphs at `\n`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextObject {
/// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub vertical: bool,
pub kind: TextKind,
/// Maps text space (origin = first baseline start for point type) to the document.
pub xf: Affine,
@@ -437,6 +440,7 @@ pub struct TextObject {
impl TextObject {
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
vertical: false,
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun { text: text.into(), style }],
+1
View File
@@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-pathops = { workspace = true }
vectorcraft-text = { workspace = true }
vectorcraft-plugins = { workspace = true }
kurbo = { workspace = true }
serde_json = { workspace = true }
+493
View File
@@ -0,0 +1,493 @@
//! Colour adjustment effects (Effect → Color Adjustments): Brightness/Contrast, Curves, Levels,
//! Hue/Saturation, Shift to Color and Temperature/Tint.
//!
//! They recolour what an object paints with, non-destructively: its fills and strokes (solid
//! colours and gradient stops), type, gradient meshes, embedded images and, on a group or layer,
//! every member — through the colour visitor the Edit Colors commands use
//! ([`vectorcraft_doc::recolor`]). Each colour keeps its colour model. An effect on one fill or
//! stroke recolours that paint only. [`adjust`] evaluates them, innermost first and each list in
//! stack order; the renderer draws the result (caching recoloured copies of embedded images) and
//! [`crate::bake_document`] writes it for export, recoloured images as new embedded images.
//! Pattern tiles, and the shadows and glows of raster effects, keep their colours.
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint, keep_model};
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_paint, recolor_node};
use vectorcraft_doc::{Document, Effect, Node, NodeKind};
use crate::merged_params;
use crate::raster::color_param;
use crate::util::{flag, num, text};
/// The colour adjustment effects, in menu order.
pub const ADJUSTMENTS: [&str; 6] =
["adjust.brightnessContrast", "adjust.curves", "adjust.hueSaturation", "adjust.levels", "adjust.shiftToColor", "adjust.temperatureTint"];
/// Is `id` a colour adjustment effect?
pub fn is_adjustment(id: &str) -> bool {
ADJUSTMENTS.contains(&id)
}
fn visible_adjustment(e: &Effect) -> bool {
e.visible && is_adjustment(&e.id)
}
/// Does `n` carry a visible colour adjustment, on the object or on one of its fills or strokes?
pub fn has_adjustment(n: &Node) -> bool {
n.appearance.effects.iter().any(visible_adjustment) || n.appearance.items.iter().any(|i| i.effects().iter().any(visible_adjustment))
}
/// The channels a tone curve (Curves, Levels) applies to.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Channel {
All,
One(usize),
}
impl Channel {
fn of(p: &Value) -> Self {
match text(p, "channel", "rgb").to_ascii_lowercase().as_str() {
"red" | "r" => Channel::One(0),
"green" | "g" => Channel::One(1),
"blue" | "b" => Channel::One(2),
_ => Channel::All,
}
}
}
/// Entries of a sampled tone curve.
const LUT: usize = 256;
/// One adjustment, its parameters normalised.
#[derive(Clone, Debug, PartialEq)]
enum Adjust {
/// Brightness and contrast, -1..1 each.
BrightnessContrast { brightness: f32, contrast: f32 },
/// A tone curve (Curves, Levels) sampled at [`LUT`] inputs, on `channel`.
Tone { channel: Channel, lut: Arc<[f32]> },
/// Hue shift in degrees, saturation and lightness -1..1; `colorize` sets the hue instead.
HueSaturation { hue: f32, saturation: f32, lightness: f32, colorize: bool },
/// Move colours `amount` (0..1) of the way to `target` (sRGB), keeping their lightness when
/// `preserve`.
ShiftToColor { target: [f32; 3], amount: f32, preserve: bool },
/// Warm/cool and magenta/green shifts, -1..1.
TemperatureTint { temperature: f32, tint: f32 },
}
/// The colour adjustments of an effect list, composed in order.
#[derive(Clone, Debug, PartialEq)]
pub struct ColorMap(Vec<Adjust>);
impl ColorMap {
/// `self`, then `next`.
pub fn then(&self, next: &ColorMap) -> ColorMap {
ColorMap(self.0.iter().chain(&next.0).cloned().collect())
}
/// Adjust display (sRGB) components 0..1.
pub fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] {
self.0.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }))
}
/// Adjust a colour, keeping its colour model (unchanged when the adjustments leave it as is).
pub fn apply(&self, c: Color) -> Color {
let rgb = c.to_rgb();
let out = self.apply_rgb(rgb);
if out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) {
return c;
}
keep_model(c, Color::rgb(out[0], out[1], out[2]))
}
/// Adjust an 8-bit sRGB pixel.
pub fn apply_rgb8(&self, [r, g, b]: [u8; 3]) -> [u8; 3] {
let out = self.apply_rgb([r, g, b].map(|v| v as f32 / 255.0));
out.map(|v| (v * 255.0).round() as u8)
}
}
/// The visible colour adjustments of `effects` as one map, `None` when there are none.
pub fn color_map(effects: &[Effect]) -> Option<ColorMap> {
let v: Vec<Adjust> =
effects.iter().filter(|e| visible_adjustment(e)).filter_map(|e| Adjust::parse(&e.id, &merged_params(&e.id, &e.params))).collect();
(!v.is_empty()).then_some(ColorMap(v))
}
/// A parameter in -100..100 as -1..1.
fn amount(p: &Value, key: &str) -> f32 {
(num(p, key, 0.0).clamp(-100.0, 100.0) / 100.0) as f32
}
/// A level 0..255 as 0..1.
fn level(p: &Value, key: &str, default: f64) -> f32 {
(num(p, key, default).clamp(0.0, 255.0) / 255.0) as f32
}
impl Adjust {
fn parse(id: &str, p: &Value) -> Option<Self> {
Some(match id {
"adjust.brightnessContrast" => Adjust::BrightnessContrast { brightness: amount(p, "brightness"), contrast: amount(p, "contrast") },
"adjust.levels" => {
let (ib, iw) = (level(p, "inputBlack", 0.0), level(p, "inputWhite", 255.0));
let (ob, ow) = (level(p, "outputBlack", 0.0), level(p, "outputWhite", 255.0));
let gamma = num(p, "gamma", 1.0).clamp(0.1, 10.0) as f32;
let f = |x: f32| {
let t = if iw > ib {
((x - ib) / (iw - ib)).clamp(0.0, 1.0)
} else if x >= ib {
1.0
} else {
0.0
};
ob + t.powf(1.0 / gamma) * (ow - ob)
};
Adjust::Tone { channel: Channel::of(p), lut: sample(f) }
}
"adjust.curves" => {
let pts = curve_points(p.get("points"));
Adjust::Tone { channel: Channel::of(p), lut: sample(|x| curve_at(&pts, x)) }
}
"adjust.hueSaturation" => Adjust::HueSaturation {
hue: num(p, "hue", 0.0).clamp(-360.0, 360.0) as f32,
saturation: amount(p, "saturation"),
lightness: amount(p, "lightness"),
colorize: flag(p, "colorize", false),
},
"adjust.shiftToColor" => Adjust::ShiftToColor {
target: color_param(p, Color::rgb(1.0, 0.5, 0.0)).to_rgb(),
amount: (num(p, "amount", 50.0).clamp(0.0, 100.0) / 100.0) as f32,
preserve: flag(p, "preserveLightness", true),
},
"adjust.temperatureTint" => Adjust::TemperatureTint { temperature: amount(p, "temperature"), tint: amount(p, "tint") },
_ => return None,
})
}
fn apply(&self, [r, g, b]: [f32; 3]) -> [f32; 3] {
match self {
Adjust::BrightnessContrast { brightness, contrast } => {
// Brightness bends the tones (black and white stay), contrast spreads them from the middle.
let gamma = 2f32.powf(-brightness);
let k = if *contrast >= 0.0 { 1.0 / (1.0 - contrast * 0.99) } else { 1.0 + contrast };
[r, g, b].map(|v| (v.clamp(0.0, 1.0).powf(gamma) - 0.5) * k + 0.5)
}
Adjust::Tone { channel, lut } => {
let f = |v: f32| lookup(lut, v);
match channel {
Channel::All => [f(r), f(g), f(b)],
Channel::One(i) => {
let mut c = [r, g, b];
if let Some(v) = c.get_mut(*i) {
*v = f(*v);
}
c
}
}
}
Adjust::HueSaturation { hue, saturation, lightness, colorize } => {
let [h, s, l] = to_hsl([r, g, b]);
let (h, s) = if *colorize {
(hue.rem_euclid(360.0), (saturation + 1.0) / 2.0)
} else {
let s = if *saturation >= 0.0 { s / (1.0 - saturation * 0.99) } else { s * (1.0 + saturation) };
((h + hue).rem_euclid(360.0), s)
};
let l = if *lightness >= 0.0 { l + (1.0 - l) * lightness } else { l * (1.0 + lightness) };
from_hsl([h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)])
}
Adjust::ShiftToColor { target, amount, preserve } => {
let to = if *preserve {
let [th, ts, _] = to_hsl(*target);
from_hsl([th, ts, to_hsl([r, g, b])[2]])
} else {
*target
};
let mix = |a: f32, b: f32| a + (b - a) * amount;
[mix(r, to[0]), mix(g, to[1]), mix(b, to[2])]
}
Adjust::TemperatureTint { temperature, tint } => {
let (t, m) = (temperature * 0.3, tint * 0.3);
[r * (1.0 + t), g * (1.0 - m), b * (1.0 - t)]
}
}
}
}
/// `f` sampled at [`LUT`] inputs 0..1.
fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> {
(0..LUT).map(|i| f(i as f32 / (LUT - 1) as f32).clamp(0.0, 1.0)).collect()
}
/// The tone curve `lut` at `v` (linear between samples).
fn lookup(lut: &[f32], v: f32) -> f32 {
let x = v.clamp(0.0, 1.0) * (LUT - 1) as f32;
let i = (x.floor() as usize).min(LUT - 2);
let (a, b) = (lut.get(i).copied().unwrap_or(v), lut.get(i + 1).copied().unwrap_or(v));
a + (b - a) * (x - i as f32)
}
/// Curves' points (0..1 each, sorted by input, one per input) from its `points` parameter:
/// `"x,y x,y …"` or `[[x, y], …]` in 0..255. Missing or unreadable points give the identity line.
pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> {
let mut pts: Vec<(f64, f64)> = match v {
Some(Value::String(s)) => s
.split(|c: char| c.is_whitespace() || c == ';')
.filter_map(|pair| {
let (x, y) = pair.split_once(',')?;
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
})
.collect(),
Some(Value::Array(a)) => a.iter().filter_map(|xy| Some((xy.get(0)?.as_f64()?, xy.get(1)?.as_f64()?))).collect(),
_ => vec![],
};
pts.retain(|(x, y)| x.is_finite() && y.is_finite());
// At most a point per level is meaningful.
pts.truncate(256);
let mut pts: Vec<(f32, f32)> =
pts.into_iter().map(|(x, y)| ((x.clamp(0.0, 255.0) / 255.0) as f32, (y.clamp(0.0, 255.0) / 255.0) as f32)).collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
if pts.len() < 2 {
return vec![(0.0, 0.0), (1.0, 1.0)];
}
pts
}
/// Curves' curve through `pts` ([`curve_points`]) at `x` (0..1): a monotone cubic, flat beyond
/// the end points.
pub fn curve_at(pts: &[(f32, f32)], x: f32) -> f32 {
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return x };
if x <= first.0 {
return first.1;
}
if x >= last.0 {
return last.1;
}
let n = pts.len();
let slope = |i: usize| -> f32 {
match (pts.get(i), pts.get(i + 1)) {
(Some(a), Some(b)) if b.0 > a.0 => (b.1 - a.1) / (b.0 - a.0),
_ => 0.0,
}
};
// Tangents: the harmonic mean of the neighbouring slopes, zero at extrema, so the curve never
// overshoots its points.
let tangent = |i: usize| -> f32 {
if i == 0 {
return slope(0);
}
if i + 1 >= n {
return slope(n - 2);
}
let (a, b) = (slope(i - 1), slope(i));
if a * b <= 0.0 { 0.0 } else { 2.0 * a * b / (a + b) }
};
let k = pts.windows(2).position(|w| x >= w[0].0 && x <= w[1].0).unwrap_or(0);
let (Some(&(x0, y0)), Some(&(x1, y1))) = (pts.get(k), pts.get(k + 1)) else { return x };
let h = x1 - x0;
if h <= 0.0 {
return y0;
}
let t = (x - x0) / h;
let (m0, m1) = (tangent(k) * h, tangent(k + 1) * h);
let (t2, t3) = (t * t, t * t * t);
(2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * m0 + (-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * m1
}
/// HSL with hue in degrees, saturation and lightness 0..1.
fn to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] {
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
let l = (max + min) / 2.0;
let d = max - min;
if d <= 1e-6 {
return [0.0, 0.0, l];
}
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
(g - b) / d + if g < b { 6.0 } else { 0.0 }
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
[h * 60.0, s, l]
}
fn from_hsl([h, s, l]: [f32; 3]) -> [f32; 3] {
if s <= 0.0 {
return [l; 3];
}
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let p = 2.0 * l - q;
let hue = |t: f32| {
let t = t.rem_euclid(1.0);
if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
}
};
let h = h / 360.0;
[hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
}
/// Gives the key of an embedded image recoloured by a map (`None`: leave it as it is).
pub type ImageHook<'a> = dyn FnMut(&str, &ColorMap) -> Option<String> + 'a;
/// The live visitor: every colour through `map`, embedded images through the hook.
struct Live<'a, 'b> {
map: &'a ColorMap,
images: &'a mut ImageHook<'b>,
}
impl ColorVisitor for Live<'_, '_> {
fn paint(&mut self, p: &mut Paint) -> bool {
map_paint(p, &|c| self.map.apply(c))
}
fn color(&mut self, c: Color) -> Color {
self.map.apply(c)
}
fn image(&mut self, key: &str) -> Option<String> {
(self.images)(key, self.map)
}
}
/// `n` with the colour adjustments in it applied, `None` when it has none: its members' first,
/// then those of its fills and strokes on their paints, then its own followed by `outer` (the
/// adjustments of an object `n` is the art of) on everything it paints. The adjustment effects
/// are gone from the result; embedded images take the keys `images` gives.
pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> Option<Node> {
let mut m: Option<Node> = None;
if let Some(ch) = n.children() {
for (i, c) in ch.iter().enumerate() {
if let Some(new) = adjust(c, None, images)
&& let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
{
*slot = Arc::new(new);
}
}
}
for (i, item) in n.appearance.items.iter().enumerate() {
if let Some(map) = color_map(item.effects())
&& let Some(it) = m.get_or_insert_with(|| n.clone()).appearance.items.get_mut(i)
{
map_paint(it.paint_mut(), &|c| map.apply(c));
it.effects_mut().retain(|e| !is_adjustment(&e.id));
}
}
let map = match (color_map(&n.appearance.effects), outer) {
(Some(own), Some(outer)) => Some(own.then(outer)),
(own, None) => own,
(None, Some(outer)) => Some(outer.clone()),
};
if let Some(map) = map {
let mm = m.get_or_insert_with(|| n.clone());
mm.appearance.effects.retain(|e| !is_adjustment(&e.id));
recolor_node(mm, Reach::ALL, &mut Live { map: &map, images });
}
m
}
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
/// `d` (named by their content), and a symbol instance stands for its symbol's art.
pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option<Node> {
let art;
let src = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone());
art = crate::reshape::as_art(n, symbol.as_deref())?;
&art
}
_ => n,
};
let mut hook = |key: &str, map: &ColorMap| -> Option<String> {
let blob = d.images.get(key)?.map_rgb(|rgb| map.apply_rgb8(rgb))?;
let k = blob.content_key();
d.images.entry(k.clone()).or_insert(blob);
Some(k)
};
adjust(src, None, &mut hook)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_doc::{Appearance, NodeId};
use vectorcraft_geom::{Rect, shapes};
fn map(id: &str, params: Value) -> ColorMap {
color_map(&[Effect { id: id.into(), params, visible: true }]).unwrap()
}
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01)
}
#[test]
fn neutral_settings_change_nothing() {
for id in ADJUSTMENTS {
let m = color_map(&[Effect { id: id.into(), params: json!({"amount": 0}), visible: true }]).unwrap();
for c in [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] {
assert!(close(m.apply_rgb(c), c), "{id} {c:?} → {:?}", m.apply_rgb(c));
}
}
let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4);
assert_eq!(map("adjust.levels", json!({})).apply(cmyk), cmyk, "an untouched colour keeps its exact values");
}
#[test]
fn adjustments_move_colours_the_expected_way() {
let grey = [0.5, 0.5, 0.5];
assert!(map("adjust.brightnessContrast", json!({"brightness": 50})).apply_rgb(grey)[0] > 0.6);
assert!(map("adjust.brightnessContrast", json!({"contrast": 50})).apply_rgb([0.7; 3])[0] > 0.8);
assert!(close(map("adjust.levels", json!({"inputBlack": 64, "inputWhite": 192})).apply_rgb([0.25, 0.5, 0.75]), [0.0, 0.5, 1.0]));
assert!(close(map("adjust.levels", json!({"outputWhite": 128, "channel": "red"})).apply_rgb([1.0; 3]), [0.502, 1.0, 1.0]));
// An S curve darkens the shadows and lightens the highlights, through its points exactly.
let s = map("adjust.curves", json!({"points": "0,0 64,32 192,224 255,255"}));
assert!(close(s.apply_rgb([64.0 / 255.0; 3]), [32.0 / 255.0; 3]));
assert!(s.apply_rgb([0.85; 3])[0] > 0.9);
let arr = map("adjust.curves", json!({"points": [[0, 255], [255, 0]]}));
assert!(close(arr.apply_rgb([0.0, 1.0, 0.25]), [1.0, 0.0, 0.75]), "an inverting curve");
assert!(close(map("adjust.hueSaturation", json!({"hue": 120})).apply_rgb([1.0, 0.0, 0.0]), [0.0, 1.0, 0.0]));
assert!(close(map("adjust.hueSaturation", json!({"saturation": -100})).apply_rgb([1.0, 0.0, 0.0]), [0.5; 3]));
assert!(close(map("adjust.hueSaturation", json!({"saturation": 100})).apply_rgb(grey), grey), "greys stay grey");
assert!(close(map("adjust.hueSaturation", json!({"colorize": true, "hue": 240, "saturation": 100})).apply_rgb(grey), [0.0, 0.0, 1.0]));
let shift = map("adjust.shiftToColor", json!({"color": "#0000ff", "amount": 100, "preserveLightness": false}));
assert!(close(shift.apply_rgb([1.0, 1.0, 0.0]), [0.0, 0.0, 1.0]));
let warm = map("adjust.temperatureTint", json!({"temperature": 100})).apply_rgb(grey);
assert!(warm[0] > grey[0] && warm[2] < grey[2]);
// Effects compose in stack order.
let both = map("adjust.levels", json!({"outputWhite": 128})).then(&map("adjust.brightnessContrast", json!({"brightness": 100})));
assert!(close(both.apply_rgb([1.0; 3]), [(128.0f32 / 255.0).sqrt(); 3]));
}
#[test]
fn adjust_recolours_paints_members_and_strips_the_effects() {
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let leaf = |id| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), red.clone()));
let mut g = Node::group(NodeId(1), vec![leaf(2), leaf(3)]);
g.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
assert!(has_adjustment(&g));
let out = adjust(&g, None, &mut |_, _| None).unwrap();
assert!(out.appearance.effects.is_empty());
for c in out.children().unwrap() {
assert_eq!(c.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
}
// A hidden effect does nothing; a stroke's own effect recolours just that stroke.
g.appearance.effects[0].visible = false;
assert!(!has_adjustment(&g) && adjust(&g, None, &mut |_, _| None).is_none());
let mut p = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
p.appearance.items[1].effects_mut().push(Effect { id: "adjust.levels".into(), params: json!({"outputBlack": 255}), visible: true });
let out = adjust(&p, None, &mut |_, _| None).unwrap();
assert_eq!(out.appearance.items[1].paint().color().unwrap().to_hex(), "#ffffff");
assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint());
assert!(out.appearance.items[1].effects().is_empty());
}
}
+57 -5
View File
@@ -6,19 +6,21 @@
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills,
//! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects
//! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images
//! become images of their own).
//!
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use crate::group::{has_own_paint, paints};
use crate::{
GeomContext, apply_geometry_with, crop_marks_art, evaluate_container, has_crop_marks, has_geometry, has_pathfinder, is_geometry, needs_outline,
reshape,
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
has_pathfinder, is_geometry, needs_outline, reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
@@ -37,10 +39,12 @@ fn clear_item_effects(item: &mut AppearanceItem) {
/// Does anything in `n`'s subtree need baking?
pub fn needs_bake(n: &Node) -> bool {
has_crop_marks(n)
has_adjustment(n)
|| has_crop_marks(n)
|| has_pathfinder(n)
|| has_own_paint(n)
|| has_geometry(&n.appearance.effects)
|| n.projection().is_some()
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
}
@@ -78,12 +82,18 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if !needs_bake(n) {
return None;
}
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
if has_adjustment(n)
&& let Some(m) = adjust_in_document(d, n)
{
return Some(bake_node(d, &m).unwrap_or(m));
}
// Crop marks: the object and its marks.
if let Some(m) = crop_marks_art(n) {
return Some(bake_pieces(d, m));
}
// Type, images, symbol instances and live objects: reshaped through their outlines.
if needs_outline(n) && has_geometry(&n.appearance.effects) {
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
@@ -272,6 +282,48 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
}
}
/// Envelope Options → Distort Appearance: path or compound path `n` as plain filled art that an
/// envelope bends as a whole: its geometry effects applied, every visible fill a copy of the path
/// painted by that fill and every visible stroke without a brush its filled outline in the
/// stroke's paint, opacity and blend mode (a brushed stroke stays a stroke), in paint order, as
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
pub fn bake_appearance(n: &Node) -> Option<Node> {
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
return None;
}
let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none());
let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i)));
if !geometric && !n.appearance.items.iter().any(outlined) {
return None;
}
let (g, rule, ctx, mut m) = leaf_base(n)?;
let piece = |path: PathData, rule: FillRule, item: AppearanceItem| {
let mut p = Node::path(n.id, path, Appearance { items: vec![item], ..Default::default() });
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
*r = rule;
}
Arc::new(p)
};
let mut pieces = vec![];
for item in n.appearance.items.iter().filter(|i| paints(i)) {
let ig = apply(item_effects(item), &g, &ctx.item(item));
let mut it = item.clone();
clear_item_effects(&mut it);
pieces.push(match &it {
AppearanceItem::Stroke(st) if st.brush.is_none() => {
let fill = FillLayer { opacity: st.opacity, blend: st.blend, ..FillLayer::new(st.paint.clone()) };
piece(crate::stroke::outline_region(&ig, rule, st), FillRule::NonZero, AppearanceItem::Fill(fill))
}
_ => piece(ig, rule, it),
});
}
m.appearance.items.clear();
m.appearance.contents_index = None;
m.kind = NodeKind::Group { children: pieces, clip: false };
Some(m)
}
/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the
/// document has none (export it as is).
pub fn bake_document(doc: &Document) -> Option<Document> {
+101 -8
View File
@@ -18,15 +18,23 @@
//! art painting its members (its raster effects then apply to the composite).
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
//! PDF writers.
//! - **Plug-in effects** (`plugin.<plug-in id>`, Effect › Plug-ins) are geometry effects run by an
//! installed WebAssembly plug-in (`vectorcraft-plugins`); [`effect_info`] describes them like
//! built-in ones, and an effect whose plug-in isn't installed leaves the geometry as it is.
//! - **Colour adjustments** (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color,
//! Temperature/Tint) recolour what an object paints with: [`adjust`] evaluates them for the
//! renderer and the exporters.
//!
//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash
//! noise (`seed` parameter, default 0).
#![forbid(unsafe_code)]
mod adjust;
mod bake;
mod clip;
mod distort;
mod group;
mod live;
mod marks;
mod raster;
mod reshape;
@@ -44,12 +52,14 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints,
pathfinder_children,
};
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
@@ -125,6 +135,8 @@ const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
const ADJUST: &[&str] = &["Effect", "Color Adjustments"];
/// The warp styles in Illustrator's Style menu order: (id suffix, label).
pub const WARP_STYLES: [(&str, &str); 15] = [
@@ -254,6 +266,50 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
];
v.extend([
g(
"adjust.brightnessContrast",
"Brightness/Contrast…",
ADJUST,
"{brightness: -100..100 (0; bends the tones, black and white stay), contrast: -100..100 (0)} recolours the object's fills, strokes, type, meshes and embedded images (each colour keeps its model)",
json!({"brightness": 0.0, "contrast": 0.0}),
),
g(
"adjust.curves",
"Curves…",
ADJUST,
"{points: \"x,y x,y …\" or [[x, y], …] (input, output 0..255; a smooth monotone curve through them, flat past the ends; \"0,0 128,128 255,255\"), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does",
json!({"points": "0,0 128,128 255,255", "channel": "rgb"}),
),
g(
"adjust.hueSaturation",
"Hue/Saturation…",
ADJUST,
"{hue: deg -180..180 (0), saturation: -100..100 (0), lightness: -100..100 (0), colorize: bool (false; true: every colour takes hue `hue` (0..360) at saturation (100 + saturation) / 2 %)} recolours as Brightness/Contrast does",
json!({"hue": 0.0, "saturation": 0.0, "lightness": 0.0, "colorize": false}),
),
g(
"adjust.levels",
"Levels…",
ADJUST,
"{inputBlack: 0..255 (0), inputWhite: 0..255 (255), gamma: 0.1..10 (1; above 1 lightens the midtones), outputBlack: 0..255 (0), outputWhite: 0..255 (255), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does",
json!({"inputBlack": 0.0, "inputWhite": 255.0, "gamma": 1.0, "outputBlack": 0.0, "outputWhite": 255.0, "channel": "rgb"}),
),
g(
"adjust.shiftToColor",
"Shift to Color…",
ADJUST,
"{color: \"#rrggbb\" (\"#ff8000\"), amount: 0..100 % (50), preserveLightness: bool (true: colours take the target's hue and saturation and keep their lightness; false: they mix with it)} recolours as Brightness/Contrast does",
json!({"color": "#ff8000", "amount": 50.0, "preserveLightness": true}),
),
g(
"adjust.temperatureTint",
"Temperature/Tint…",
ADJUST,
"{temperature: -100 (cooler) .. 100 (warmer) (0), tint: -100 (greener) .. 100 (more magenta) (0)} recolours as Brightness/Contrast does",
json!({"temperature": 0.0, "tint": 0.0}),
),
]);
for (suffix, label) in WARP_STYLES {
let id: &'static str = match suffix {
"arc" => "warp.arc",
@@ -293,21 +349,47 @@ fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInf
INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect())
}
/// Catalogue entry for `id`.
/// Catalogue entry for `id` (built-in, or an installed plug-in effect).
pub fn effect_info(id: &str) -> Option<EffectInfo> {
catalog_index().get(id).cloned()
catalog_index().get(id).cloned().or_else(|| plugin_info(id))
}
/// The catalogue entry of the installed effect plug-in with effect id `id` (`plugin.<id>`).
fn plugin_info(id: &str) -> Option<EffectInfo> {
use vectorcraft_plugins::registry::intern;
let p = vectorcraft_plugins::effect::installed(id)?;
let m = p.manifest();
let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" };
Some(EffectInfo {
id: intern(id),
label: intern(&format!("{}{dots}", m.name)),
menu: PLUGINS,
params: intern(&m.params_doc()),
defaults: Value::Object(m.defaults()),
raster: false,
lengths: Lengths::default(),
})
}
/// The installed effect plug-ins as catalogue entries (Effect › Plug-ins), by plug-in id.
pub fn plugin_effects() -> Vec<EffectInfo> {
vectorcraft_plugins::registry::list().iter().filter_map(|p| plugin_info(&vectorcraft_plugins::effect::effect_id(p.id()))).collect()
}
/// Dialog defaults for `id` (`None` for unknown effects).
pub fn default_params(id: &str) -> Option<Value> {
catalog_index().get(id).map(|e| e.defaults.clone())
match catalog_index().get(id) {
Some(e) => Some(e.defaults.clone()),
None => plugin_info(id).map(|e| e.defaults),
}
}
/// `params` layered over the defaults of `id` (unknown keys are kept).
pub fn merged_params(id: &str, params: &Value) -> Value {
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
Some(Value::Object(m)) => m.clone(),
_ => Map::new(),
Some(_) => Map::new(),
None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(),
};
if let Value::Object(p) = params {
for (k, v) in p {
@@ -319,7 +401,9 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
/// A new effect record with default parameters (overridden by `params`).
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
catalog_index().get(id)?;
if !catalog_index().contains_key(id) {
vectorcraft_plugins::effect::installed(id)?;
}
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
}
@@ -346,9 +430,15 @@ pub fn is_raster(id: &str) -> bool {
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
}
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`].)
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour
/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in
/// leaves the geometry as it is).
pub fn is_geometry(id: &str) -> bool {
!is_raster(id) && !is_pathfinder(id) && id != CROP_MARKS && catalog_index().contains_key(id)
!is_raster(id)
&& !is_pathfinder(id)
&& !is_adjustment(id)
&& id != CROP_MARKS
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
}
/// Any visible geometry effect in the list?
@@ -420,6 +510,9 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
use util::*;
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone());
}
match id {
"distort.freeDistort" => distort::free_distort(path, b, p),
"distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)),
+57
View File
@@ -0,0 +1,57 @@
//! Live objects (blends, envelopes, gradient meshes, repeats) evaluated alike for the renderer and
//! every exporter, with what the pure evaluation in [`vectorcraft_doc::live`] can't do itself
//! ([`Hooks`]): type inside an envelope distorts as its glyph outlines, run by run
//! ([`outline_text`]); symbol instances as their symbol's art; with Distort Appearance strokes and
//! geometry effects bend with the art ([`bake_appearance`]); with Distort Pattern Fills the pattern
//! tiles do.
use std::sync::Arc;
use vectorcraft_doc::live::{self, Hooks, Outliner};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect};
use crate::{bake_appearance, outline_text};
/// The text outliner live evaluation uses: type as its glyph outlines, one path per run in the
/// run's paint ([`outline_text`]).
pub fn text_outliner() -> Outliner<'static> {
Some(&outline_text)
}
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance.
fn outline(doc: Option<&Document>, n: &Node) -> Option<Node> {
match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => {
let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?;
let mut art = (*sym.art).clone();
art.transform(*xf, false);
Some(Node::group(n.id, vec![Arc::new(art)]))
}
_ => outline_text(n),
}
}
/// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a
/// document, symbol instances and pattern tiles stay as they are).
fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
let outline = |n: &Node| outline(doc, n);
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles) })
}
/// One level of evaluation of live object `n` ([`live::expand_live_hooks`]).
pub fn expand_live(doc: Option<&Document>, n: &Node) -> Vec<Node> {
with_hooks(doc, |h| live::expand_live_hooks(n, h))
}
/// Live object `n` evaluated into a plain group ([`live::expanded_group_hooks`]): Expand.
pub fn expanded_live_group(doc: Option<&Document>, n: &Node) -> Node {
with_hooks(doc, |h| live::expanded_group_hooks(n, h))
}
/// `n` with every live object in it replaced by its evaluated art ([`live::expand_deep_hooks`]):
/// what the exporters write for live objects.
pub fn expand_live_deep(doc: Option<&Document>, n: &Node) -> Node {
with_hooks(doc, |h| live::expand_deep_hooks(n, h))
}
+5 -4
View File
@@ -64,7 +64,8 @@ pub fn blend_mode(s: &str) -> BlendMode {
BlendMode::ALL.iter().copied().find(|m| format!("{m:?}").to_ascii_lowercase() == key).unwrap_or(BlendMode::Normal)
}
fn color(p: &Value, default: Color) -> Color {
/// An effect's `color` parameter (`"#rrggbb"`, `[r, g, b]` 0..1 or a colour object).
pub(crate) fn color_param(p: &Value, default: Color) -> Color {
match p.get("color") {
Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default),
Some(Value::Array(a)) if a.len() >= 3 => {
@@ -95,19 +96,19 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
dx: num(&p, "x", 7.0).clamp(-1e4, 1e4),
dy: num(&p, "y", 7.0).clamp(-1e4, 1e4),
blur,
color: color(&p, Color::BLACK),
color: color_param(&p, Color::BLACK),
},
"stylize.outerGlow" => RasterFx::OuterGlow {
mode: blend_mode(text(&p, "mode", "screen")),
opacity: opacity(&p),
blur,
color: color(&p, Color::rgb(1.0, 1.0, 0.0)),
color: color_param(&p, Color::rgb(1.0, 1.0, 0.0)),
},
"stylize.innerGlow" => RasterFx::InnerGlow {
mode: blend_mode(text(&p, "mode", "screen")),
opacity: opacity(&p),
blur,
color: color(&p, Color::WHITE),
color: color_param(&p, Color::WHITE),
center: text(&p, "source", "edge").eq_ignore_ascii_case("center"),
},
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
+33 -2
View File
@@ -113,7 +113,7 @@ fn outline_nested_text(n: &mut Node) {
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if geometry_effects(n).is_empty() || !needs_outline(n) {
if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) {
return None;
}
as_art(n, symbol_art)
@@ -131,7 +131,7 @@ pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
}
/// [`reshape`] without its checks (no geometry effects: just the art).
fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {
@@ -146,6 +146,10 @@ fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
_ => outline_art(n, symbol_art)?,
};
reshape_leaves(&mut art, &fx);
// Type and symbols in perspective: the art projected onto their plane.
if let Some(h) = n.projection() {
project_leaves(&mut art, &h);
}
// The art is a group with `n`'s id (a clip group for an image).
let mut out = art;
out.name = n.name.clone();
@@ -159,6 +163,33 @@ fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
Some(out)
}
/// Map every path under `art` (and its gradients) through the projective map `h`, curves split
/// finely enough to follow it.
fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography) {
let f = |p: vectorcraft_geom::Point| h.apply(p).unwrap_or(p);
let piece = art.geometric_bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25));
let project = |path: &mut PathData| *path = vectorcraft_doc::live::map_nonlinear(path, piece, f);
for_each_leaf(art, &mut |leaf| {
leaf.pin_gradients();
leaf.appearance.warp_gradients(&|p| h.affine_at(p).unwrap_or(vectorcraft_geom::Affine::IDENTITY));
match &mut leaf.kind {
NodeKind::Path { path, live, .. } => {
project(path);
*live = None;
}
NodeKind::Compound { children, .. } => {
for c in children.iter_mut() {
if let NodeKind::Path { path, live, .. } = &mut Arc::make_mut(c).kind {
project(path);
*live = None;
}
}
}
_ => {}
}
});
}
/// The visible geometry effects of `n`'s own effect list, in order.
pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect()
+4
View File
@@ -22,6 +22,9 @@ vectorcraft-svg = { workspace = true }
vectorcraft-format = { workspace = true }
vectorcraft-pdf = { workspace = true }
vectorcraft-cad = { workspace = true }
vectorcraft-eps = { workspace = true }
vectorcraft-metafile = { workspace = true }
vectorcraft-plugins = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
kurbo = { workspace = true }
@@ -33,3 +36,4 @@ log = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
wat = { workspace = true }
+227
View File
@@ -0,0 +1,227 @@
//! Window → Asset Export and Object → Collect for Export: art collected as assets
//! ([`vectorcraft_doc::ExportAsset`]), exported each on its own, cropped to its art, at the export
//! settings Export for Screens shares (`document.exportSettings`). File → Export Selection collects
//! the selection this way and opens Export for Screens on its Assets tab (a UI command).
use serde_json::{Map, Value, json};
use vectorcraft_doc::{ExportAsset, NodeId};
use super::edit::{roots_of, selected_roots};
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_screens, store_screen_settings};
use super::*;
/// The longest asset name kept (characters).
const MAX_NAME: usize = 255;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"assets.add",
"Collect for Export",
["Object", "Collect for Export"],
None,
"{ids?: [id…] (default: the selected objects), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset",
has_doc,
add
),
cmd!(
"assets.remove",
"Remove Asset",
[],
None,
"{assets: [asset id…]} take assets out of the Asset Export panel (their art stays) → {removed: n}",
has_doc,
remove
),
cmd!("assets.rename", "Rename Asset", [], None, "{asset: id, name} rename an asset (its files' names)", has_doc, rename),
cmd!(
query "assets.list",
"Assets",
[],
None,
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the visual bounds of its art)}]} the Asset Export panel's assets, in order",
has_doc,
list
),
cmd!(
"assets.export",
"Export Assets",
[],
None,
"{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
has_doc,
export
),
cmd!(
"assets.settings.set",
"Asset Export Settings",
[],
None,
"{formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step",
has_doc,
settings_set
),
]
}
/// `e` as an error of command `cmd` (for errors of the export underneath).
fn of_cmd(cmd: &str, e: EngineError) -> EngineError {
match e {
EngineError::BadParams { msg, .. } => bad(cmd, msg),
e => e,
}
}
/// `name` trimmed and at most [`MAX_NAME`] characters long (`None` when empty).
fn clean_name(name: &str) -> Option<String> {
let n: String = name.trim().chars().take(MAX_NAME).collect();
(!n.is_empty()).then_some(n)
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.add";
let ids = match ids_param(p, "ids") {
Some(ids) => {
let d = &s.doc()?.doc;
roots_of(d, d.paint_order(ids))
}
None => selected_roots(s)?,
};
if ids.is_empty() {
return Err(bad(C, "select the art to collect (or give its ids)"));
}
let name = str_param(p, "name").and_then(clean_name);
let sets: Vec<Vec<NodeId>> = if bool_or(p, "multiple", true) { ids.iter().map(|id| vec![*id]).collect() } else { vec![ids] };
if name.is_some() && sets.len() > 1 {
return Err(bad(C, "a name names one asset: give multiple: false, or one object"));
}
let existing = |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes).map(|a| a.id);
let d = &s.doc()?.doc;
// Nothing new: no undo step.
if sets.iter().all(|set| existing(d, set).is_some()) {
return Ok(json!({ "assets": sets.iter().filter_map(|set| existing(d, set)).collect::<Vec<_>>(), "added": 0 }));
}
s.edit("Collect for Export", |d, _| {
let (mut out, mut added) = (vec![], 0);
for nodes in sets {
if let Some(id) = existing(d, &nodes) {
out.push(id);
continue;
}
let own = match nodes.as_slice() {
[one] => d.node(*one).and_then(|n| n.name.as_deref()).and_then(clean_name),
_ => None,
};
let name = name.clone().or(own).unwrap_or_else(|| d.next_asset_name());
let id = d.alloc_id().0;
d.assets.push(ExportAsset { id, name, nodes });
out.push(id);
added += 1;
}
Ok(json!({ "assets": out, "added": added }))
})
}
/// The asset ids of param `key`.
fn asset_ids(p: &Value, key: &str, cmd: &str) -> Result<Vec<u64>> {
let ids = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be an array of asset ids (see assets.list)")))?;
ids.iter().map(|v| v.as_u64().ok_or_else(|| bad(cmd, format!("{v} is not an asset id")))).collect()
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.remove";
let ids = asset_ids(p, "assets", C)?;
let n = s.doc()?.doc.assets.iter().filter(|a| ids.contains(&a.id)).count();
if n == 0 {
return Err(bad(C, "no such assets (see assets.list)"));
}
s.edit("Remove Asset", |d, _| {
d.assets.retain(|a| !ids.contains(&a.id));
Ok(json!({ "removed": n }))
})
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.rename";
let id = p.get("asset").and_then(Value::as_u64).ok_or_else(|| bad(C, "`asset` is the asset id (see assets.list)"))?;
let name = str_param(p, "name").and_then(clean_name).ok_or_else(|| bad(C, "give the asset a name"))?;
let at = s.doc()?.doc.assets.iter().position(|a| a.id == id).ok_or_else(|| bad(C, format!("no asset {id}")))?;
s.edit("Rename Asset", |d, _| {
if let Some(a) = d.assets.get_mut(at) {
a.name = name;
}
ok()
})
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let rows: Vec<Value> = d
.assets
.iter()
.map(|a| {
let bounds = a.nodes.iter().filter_map(|id| d.node(*id)?.visual_bounds()).reduce(|x, y| x.union(y));
json!({
"id": a.id,
"name": a.name,
"nodes": a.nodes.iter().map(|n| n.0).collect::<Vec<_>>(),
"bounds": bounds.map(|b| [b.x0, b.y0, b.width(), b.height()]),
})
})
.collect();
Ok(json!({ "assets": rows }))
}
/// The shared export settings of the document (the [`SCREEN_SETTINGS_KEYS`] it has).
fn shared_settings(s: &Session) -> Result<Map<String, Value>> {
let saved = &s.doc()?.doc.export_settings;
Ok(SCREEN_SETTINGS_KEYS.iter().filter_map(|k| Some((k.to_string(), saved.get(*k).filter(|v| !v.is_null())?.clone()))).collect())
}
/// `base` with the settings of `p` over it: a given value replaces (null removes) the saved one;
/// formats drop a saved preset and a preset (`""`: none) saved formats.
fn overlay(base: &mut Map<String, Value>, p: &Map<String, Value>) {
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
if given("formats") && !p.contains_key("preset") {
base.remove("preset");
}
if given("preset") && !p.contains_key("formats") {
base.remove("formats");
}
for (k, v) in p {
match v {
Value::Null => _ = base.remove(k),
Value::String(id) if k == "preset" && id.is_empty() => _ = base.remove(k),
v => _ = base.insert(k.clone(), v.clone()),
}
}
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.export";
let mut q = shared_settings(s)?;
if let Some(o) = p.as_object() {
overlay(&mut q, o);
}
if !q.contains_key("assets") {
let all: Vec<u64> = s.doc()?.doc.assets.iter().map(|a| a.id).collect();
if all.is_empty() {
return Err(bad(C, "no assets yet: collect art for export first (assets.add)"));
}
q.insert("assets".into(), json!(all));
}
export_screens(s, &Value::Object(q)).map_err(|e| of_cmd(C, e))
}
fn settings_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.settings.set";
let o = p.as_object().ok_or_else(|| bad(C, "give the settings to change, e.g. {\"formats\": [{\"format\": \"png\", \"scale\": \"2x\"}]}"))?;
if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str())) {
return Err(bad(C, format!("`{k}` isn't an export setting: {}", SCREEN_SETTINGS_KEYS.join(", "))));
}
let mut settings = s.doc()?.doc.export_settings.clone();
overlay(&mut settings, o);
check_screen_settings(s, &Value::Object(settings.clone())).map_err(|e| of_cmd(C, e))?;
store_screen_settings(s, settings)?;
Ok(json!({ "settings": shared_settings(s)? }))
}
+8 -51
View File
@@ -16,7 +16,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _};
use vectorcraft_geom::{FillRule, Point, Rect, Shape as _};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
self as b, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, edge_near, face_at, is_live_paint, sample_polyline, shapes_for, sorted_roots,
@@ -205,52 +205,6 @@ fn stroke_only(paint: Paint, width: f64) -> Appearance {
Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], ..Default::default() }
}
/// A point strictly inside a filled path (for re-finding faces after a rebuild).
pub(crate) fn interior_point(path: &PathData) -> Option<Point> {
let bb = path.bounds()?;
let bp = path.to_bezpath();
let mut edges: Vec<(Point, Point)> = vec![];
let (mut first, mut last) = (Point::ZERO, Point::ZERO);
kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el {
kurbo::PathEl::MoveTo(p) => {
if last != first {
edges.push((last, first));
}
first = p;
last = p;
}
kurbo::PathEl::LineTo(p) => {
edges.push((last, p));
last = p;
}
kurbo::PathEl::ClosePath => {
if last != first {
edges.push((last, first));
}
last = first;
}
_ => {}
});
if last != first {
edges.push((last, first));
}
let mut best: Option<(f64, Point)> = None;
for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] {
let y = bb.y0 + bb.height() * f;
let mut xs: Vec<f64> =
edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect();
xs.sort_by(f64::total_cmp);
for w in xs.windows(2) {
let m = Point::new((w[0] + w[1]) / 2.0, y);
let width = w[1] - w[0];
if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 {
best = Some((width, m));
}
}
}
best.map(|b| b.1)
}
// ---------- Shape Builder ----------
fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
@@ -395,7 +349,11 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
src_group.name = Some(SOURCES_NAME.into());
src_group.visible = false;
children.push(Arc::new(src_group));
let regs = po::regions(&shapes);
// Every path is an edge, open ones included; faces are the areas they enclose.
let (regs, edges) = po::live_paint(&shapes);
if regs.is_empty() && edges.is_empty() {
return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into()));
}
let nfaces = regs.len();
for r in regs {
let id = d.alloc_id();
@@ -404,10 +362,9 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
n.name = Some(FACE_NAME.into());
children.push(Arc::new(n));
}
let edges = po::pathfinder(po::PathfinderOp::Outline, &shapes);
let nedges = edges.len();
for e in edges {
let (_, stroke, w) = &styles[e.key as usize];
let Some((_, stroke, w)) = styles.get(e.key as usize) else { continue };
let id = d.alloc_id();
let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w));
n.name = Some(EDGE_NAME.into());
@@ -451,7 +408,7 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
.iter()
.filter(|f| !f.appearance.fill_paint().is_none())
.filter_map(|f| Some((interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
.collect();
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
.iter()
+1 -1
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
use vectorcraft_geom::Rect;
pub use flavours::{BITMAP, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT};
pub use flavours::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT};
pub(crate) use resources::SwatchChoices;
use super::*;
+29 -2
View File
@@ -1,5 +1,5 @@
//! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what
//! Paste takes from them (bitmaps, plain text and PDF).
//! Paste takes from them (bitmaps, plain text, PDF and Windows metafiles).
//!
//! The UI owns the platform clipboard (`Services::system_clipboard` in the egui frontend): on Copy
//! it publishes [`Session::clipboard_flavours`]; before a Paste it turns what another app put on
@@ -24,9 +24,12 @@ pub const PDF: &str = "application/pdf";
pub const PNG: &str = "image/png";
/// Any bitmap (PNG, BMP…), in what Paste asks the system clipboard for.
pub const BITMAP: &str = "image/*";
/// An EMF (or WMF) picture: what Windows apps copy as vector art (the system clipboard reads it
/// where the platform offers it).
pub const EMF: &str = "image/emf";
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
/// (word processors offer a picture of copied text too).
pub const PASTE_ORDER: [&str; 4] = [SVG, PDF, TEXT, BITMAP];
pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP];
/// One representation of the clipboard's contents.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -105,6 +108,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
import_pdf
),
cmd!(
query "clipboard.importEmf",
"Load Metafile into Clipboard",
[],
None,
"{dataBase64 (EMF or WMF bytes), center?: [x, y]} replace the clipboard with the picture's objects (paths, clipping groups, images, point type; the images they use), centred on `center` (default: the first artboard) → {count, warnings}; records VectorCraft doesn't read are skipped with one warning; then run edit.paste",
has_doc,
import_emf
),
]
}
@@ -277,6 +289,21 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "count": count }))
}
fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "clipboard.importEmf";
let bytes = data_param(p, C)?;
if vectorcraft_metafile::sniff(&bytes).is_none() {
return Err(bad(C, "the data isn't an EMF or WMF picture"));
}
let loaded = fileio::load("Clipboard.emf", &bytes).map_err(|e| bad(C, e.to_string()))?;
let clip = Clipboard::from_document(&loaded.doc);
if clip.is_empty() {
return Err(bad(C, "the picture has no objects"));
}
let count = s.load_clipboard(clip, p)?;
Ok(json!({ "count": count, "warnings": loaded.warnings }))
}
fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "clipboard.importPdf";
let bytes = data_param(p, C)?;
+30 -78
View File
@@ -13,7 +13,9 @@ use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation};
use vectorcraft_color::{Color, Paint, keep_model};
use vectorcraft_doc::live::lerp_color;
use vectorcraft_doc::pattern::PatternDef;
use vectorcraft_doc::{AppearanceItem, Document, ImageBlob, Node, NodeId, NodeKind};
pub(crate) use vectorcraft_doc::recolor::map_paint;
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node};
use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind};
use super::edit::selected_roots;
use super::*;
@@ -144,30 +146,6 @@ fn gray_level(c: Color) -> f32 {
/// gradient stop): `(colour, link, tint)`, true when it changed them.
pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
/// Apply `f` to the solid colour or gradient stops of a paint ([`Paint::map_links`]); a gradient
/// that changes is no longer its gradient swatch's. Returns whether anything changed.
fn map_links(p: &mut Paint, f: &LinkMap) -> bool {
let changed = p.map_links(&mut |c, l, t| f(c, l, t));
if changed && let Paint::Gradient(g) = p {
g.swatch = None;
}
changed
}
/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether
/// anything changed.
pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
map_links(p, &|c, link, _| {
let n = f(*c);
if n == *c {
return false;
}
*c = n;
*link = None;
true
})
}
/// The identity colour map (a pass that only maps linked colours).
fn same(c: Color) -> Color {
c
@@ -258,68 +236,24 @@ impl<'a> Recolor<'a> {
out
}
/// `n` recoloured, or `None` when nothing changed.
/// `n` recoloured (through the shared colour visitor), or `None` when nothing changed.
fn subtree(&mut self, d: &Document, n: &Node) -> Option<Node> {
let before = self.changed;
let mut m = n.clone();
self.node(d, &mut m);
(self.changed > before).then_some(m)
}
fn node(&mut self, d: &Document, n: &mut Node) {
let Scope { fill, stroke, images, .. } = self.scope;
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if fill => self.paint(d, &mut l.paint),
AppearanceItem::Stroke(l) if stroke => self.paint(d, &mut l.paint),
_ => {}
}
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
if fill {
self.paint(d, &mut r.style.fill);
}
if stroke {
self.paint(d, &mut r.style.stroke);
}
}
}
NodeKind::Mesh(m) if fill => {
let mut ch = false;
for p in &mut m.points {
let c = (self.f)(p.color);
ch |= c != p.color;
p.color = c;
}
self.changed += ch as usize;
}
NodeKind::Image(im) if images && im.link.is_none() => {
if let Some(k) = self.image(d, &im.key) {
im.key = k;
self.changed += 1;
}
}
_ => {
for c in n.children_mut().into_iter().flatten() {
if let Some(new) = self.subtree(d, c) {
*c = Arc::new(new);
}
}
}
}
let mut m = n.clone();
let changed = recolor_node(&mut m, Reach { fill, stroke, images }, &mut Visit { pass: self, d });
self.changed += changed;
(changed > 0).then_some(m)
}
fn paint(&mut self, d: &Document, p: &mut Paint) {
let changed = match p {
/// Recolour one paint: true when it changed.
fn paint(&mut self, d: &Document, p: &mut Paint) -> bool {
match p {
Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(),
_ => match self.links {
Some(f) => map_links(p, f),
None => map_paint(p, self.f),
},
};
self.changed += changed as usize;
}
}
/// The key of image `key` recoloured (one copy per pass), `None` when no pixel changes.
@@ -370,6 +304,24 @@ impl<'a> Recolor<'a> {
}
}
/// What a pass over document `d` does to each colour the shared visitor meets.
struct Visit<'p, 'a> {
pass: &'p mut Recolor<'a>,
d: &'p Document,
}
impl ColorVisitor for Visit<'_, '_> {
fn paint(&mut self, p: &mut Paint) -> bool {
self.pass.paint(self.d, p)
}
fn color(&mut self, c: Color) -> Color {
(self.pass.f)(c)
}
fn image(&mut self, key: &str) -> Option<String> {
self.pass.image(self.d, key)
}
}
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
run_pass(s, p, label, Recolor::new(Scope::of(p), f))
}
+2 -1
View File
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
has_doc,
text_create
),
@@ -401,6 +401,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let y = f64_req(p, "y", "text.create")?;
let text = str_param(p, "text").unwrap_or("");
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
if let Some(a) = p.get("area") {
let w = f64_or(a, "width", 200.0);
let h = f64_or(a, "height", 100.0);
+129
View File
@@ -0,0 +1,129 @@
//! Window → CSS Properties: the CSS web pages style objects with ([`vectorcraft_svg::css_rules`],
//! which writes each property as SVG export writes it), for the selection or the whole document,
//! and the `.css` file with the pictures of the art it rasterizes.
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
use super::fileio::{default_name, encode, isolated, write_file};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "css.selection",
"CSS Properties",
[],
None,
"{ids?: [id…] (default: the selection), units?: px|pt|mm|cm|in (default px; 1 px = 1 pt, as in SVG export), position?: false (position: absolute, left and top from the artboard's top-left), dimensions?: true (width, height), unnamed?: true (unnamed objects too, classes named after their kind; false: named objects only), rasterize?: false (art CSS can't describe as background-image: url(<class>.png), its picture, which css.export writes)} the CSS web pages style the objects with, one rule per object back to front (layers and plain groups stand for what they hold): a rectangle's or ellipse's fill as background-color or linear-/radial-gradient(), stroke as border, corners as border-radius; type's font, color, spacing and alignment; opacity, mix-blend-mode, shadows and glows (box-shadow, text-shadow), blur (filter) → {css, rules: [{id, selector, css, unsupported?: why CSS can't describe the art exactly (without rasterize it is written as its box), image?: the PNG it refers to}], skipped: n unnamed objects left out, warnings}",
has_doc,
selection
),
cmd!(query "css.generate", "Generate CSS", [], None, "{…css.selection options} css.selection of every object in the document", has_doc, generate),
cmd!(
"css.export",
"Export CSS",
[],
None,
"{path?, scope?: selection|all (default selection; ids? picks the objects), …css.selection options} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
has_doc,
export
),
]
}
/// The CSS options of `p` (its other params are the command's own).
fn options(p: &Value, cmd: &str) -> Result<CssOptions> {
if !p.is_object() {
return Ok(CssOptions::default());
}
CssOptions::deserialize(p).map_err(|e| bad(cmd, format!("CSS options: {e}")))
}
/// The CSS of objects `ids` (back to front) with the options of `p`.
fn sheet(s: &Session, p: &Value, ids: Vec<NodeId>, cmd: &str) -> Result<CssSheet> {
let opts = options(p, cmd)?;
let d = &s.doc()?.doc;
Ok(css_rules(d, &d.paint_order(ids), &opts))
}
/// Every object of the active document: its layers.
fn everything(s: &Session) -> Result<Vec<NodeId>> {
Ok(s.doc()?.doc.layers.iter().map(|l| l.id).collect())
}
fn result(sheet: &CssSheet) -> Value {
let rules: Vec<Value> = sheet
.rules
.iter()
.map(|r| {
let mut v = json!({ "id": r.id.0, "selector": r.selector, "css": r.text() });
if let Some(why) = r.unsupported {
v["unsupported"] = json!(why);
}
if let Some(image) = &r.image {
v["image"] = json!(image);
}
v
})
.collect();
json!({ "css": sheet.text(), "rules": rules, "skipped": sheet.skipped, "warnings": sheet.warnings })
}
fn selection(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
Ok(result(&sheet(s, p, ids, "css.selection")?))
}
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = everything(s)?;
Ok(result(&sheet(s, p, ids, "css.generate")?))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "css.export";
let ids = match str_param(p, "scope").unwrap_or("selection") {
"selection" => targets(s, p)?,
"all" => everything(s)?,
other => return Err(bad(C, format!("scope must be selection or all (got {other})"))),
};
let sheet = sheet(s, p, ids, C)?;
if sheet.rules.is_empty() {
let why = if sheet.skipped > 0 {
"the objects are unnamed: name them, or give unnamed: true"
} else {
"no objects to export: select some, or give scope: all"
};
return Err(bad(C, why));
}
let css = format!("{}\n", sheet.text());
let doc = &s.doc()?.doc;
// The pictures of rasterized art.
let mut images = Vec::new();
for r in &sheet.rules {
let Some(name) = &r.image else { continue };
let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?;
images.push((name, encode(&d, "png", &json!({}))?));
}
let n = sheet.rules.len();
match str_param(p, "path") {
Some(path) => {
write_file(path, css.as_bytes())?;
let folder = std::path::Path::new(path).parent();
let mut written = Vec::new();
for (name, bytes) in &images {
let at = folder.map_or_else(|| name.to_string(), |f| f.join(name).to_string_lossy().into_owned());
write_file(&at, bytes)?;
written.push(at);
}
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": sheet.warnings }))
}
None => {
let images: Vec<Value> =
images.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": sheet.warnings }))
}
}
}
+397
View File
@@ -0,0 +1,397 @@
//! Cutting tools' commands: Mirror & Cut, Line Cut and Rectangle Cut.
//!
//! They work on the selected paths and compound paths (inside groups too; a compound path is cut
//! as one shape): filled shapes through `vectorcraft-pathops` booleans against a half-plane or a
//! rectangle, open paths by splitting them where they cross the line or the rectangle's edge (the
//! Knife and Path Eraser helpers). Each command is one undo step; the tools in
//! `vectorcraft_tools::cut` preview it live while dragging.
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Anchor, FillRule, PathData, Point, Rect, SubPath, Vec2};
use vectorcraft_pathops as po;
use vectorcraft_pathops::BoolOp;
use super::draw2::{components, erase_outline, ids_json, replace_with_pieces};
use super::edit::selected_roots;
use super::pathops::node_path;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"path.mirrorCut",
"Mirror & Cut",
[],
None,
"{axis?: \"vertical\"|\"horizontal\"|\"free\" (vertical), from?: [x,y], to?: [x,y] (free: the axis through both; vertical/horizontal: the axis through `from`, default the selection's centre), keep?: \"left\"|\"right\"|\"top\"|\"bottom\" (the side kept; default left, or top for a horizontal-ish axis)} cut the selected paths and compound paths at the axis, reflect the kept half across it and join both halves into one closed shape (open paths are joined where they meet the axis); art wholly on the other side is deleted, as one undo step → {ids, removed}",
has_selection,
mirror_cut
),
cmd!(
"path.lineCut",
"Line Cut",
[],
None,
"{from: [x,y], to: [x,y]} split the selected paths and compound paths along the line through both points, extended across the art: filled shapes become their pieces on each side (holes kept), open paths split where they cross; as one undo step → {ids, removed: 0}",
has_selection,
line_cut
),
cmd!(
"path.rectCut",
"Rectangle Cut",
[],
None,
"{rect: [x, y, width, height]} or {from: [x,y], to: [x,y]} (opposite corners) crop the selected paths and compound paths to the rectangle as real geometry (not a clipping mask): filled shapes are intersected with it, open paths keep their parts inside, art wholly outside is deleted; as one undo step → {ids, removed}",
has_selection,
rect_cut
),
]
}
/// Largest coordinate the cut commands accept (keeps the half-plane polygons finite).
const MAX_COORD: f64 = 1e7;
/// How close an open path's end must be to the axis to be joined to its reflection.
const AXIS_TOL: f64 = 1e-3;
/// A straight line through `p` along the unit vector `d`.
#[derive(Clone, Copy, Debug)]
struct Axis {
p: Point,
d: Vec2,
}
impl Axis {
/// The line through `a` and `b` (`None` when they coincide).
fn through(a: Point, b: Point) -> Option<Self> {
let v = b - a;
let len = v.hypot();
(len > 1e-9 && len.is_finite()).then(|| Axis { p: a, d: v / len })
}
/// Signed distance of `q` from the line: positive on the side `d` points to when turned a
/// quarter turn clockwise on screen (y pointing down).
fn side(&self, q: Point) -> f64 {
self.d.cross(q - self.p)
}
/// `q` moved onto the line.
fn project(&self, q: Point) -> Point {
self.p + self.d * self.d.dot(q - self.p)
}
/// The reflection across the line.
fn reflection(&self) -> Affine {
let (x, y) = (self.d.x, self.d.y);
let (a, b, d) = (2.0 * x * x - 1.0, 2.0 * x * y, 2.0 * y * y - 1.0);
let p = self.p;
Affine::new([a, b, b, d, p.x - (a * p.x + b * p.y), p.y - (b * p.x + d * p.y)])
}
/// A polygon covering everything in `around` on one side of the line (`sign` > 0: where
/// [`Axis::side`] is positive).
fn half_plane(&self, sign: f64, around: Rect) -> PathData {
let r = around.width() + around.height() + self.p.distance(around.center()) + 10.0;
let n = Vec2::new(-self.d.y, self.d.x) * sign.signum() * 2.0 * r;
let (a, b) = (self.p - self.d * r, self.p + self.d * r);
polygon(&[a, b, b + n, a + n])
}
}
fn polygon(pts: &[Point]) -> PathData {
PathData::single(SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true))
}
/// A point param that must be finite and within [`MAX_COORD`].
fn point_req(p: &Value, key: &str, cmd: &str) -> Result<Point> {
let q = point_param(p, key).ok_or_else(|| bad(cmd, format!("missing point `{key}` ([x, y])")))?;
if q.x.abs() > MAX_COORD || q.y.abs() > MAX_COORD {
return Err(bad(cmd, format!("`{key}` is out of range")));
}
Ok(q)
}
/// The editable paths and compound paths in the selection (inside groups and layers too; a
/// compound path is one target; guides and clipping paths are left alone).
fn cut_targets(s: &Session) -> Result<Vec<NodeId>> {
fn visit(n: &Node, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } => out.push(n.id),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)),
_ => {}
}
}
let roots = selected_roots(s)?;
let doc = &s.doc()?.doc;
let mut out = vec![];
for id in roots {
if let Some(n) = doc.node(id) {
visit(n, &mut out);
}
}
out.dedup();
out.retain(|id| doc.is_editable(*id));
Ok(out)
}
/// What a cut does to one object: its new pieces (none deletes it).
type Plan = Vec<(NodeId, Vec<PathData>)>;
/// Run `cut` on every target's geometry (`None`: unchanged) and apply the result as one undo step.
fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, FillRule) -> Option<Vec<PathData>>) -> Result<Value> {
let targets = cut_targets(s)?;
if targets.is_empty() {
return Err(bad(cmd, "select paths or compound paths"));
}
let doc = &s.doc()?.doc;
let mut plan: Plan = vec![];
for id in targets {
let Some((pd, rule)) = doc.node(id).and_then(node_path) else { continue };
if pd.subpaths.iter().flat_map(|sp| &sp.anchors).any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())) {
return Err(bad(cmd, "paths contain invalid coordinates"));
}
if let Some(pieces) = cut(&pd, rule) {
plan.push((id, pieces));
}
}
apply(s, label, plan)
}
fn apply(s: &mut Session, label: &str, plan: Plan) -> Result<Value> {
if plan.is_empty() {
return Ok(json!({ "ids": [], "removed": 0 }));
}
let (ids, removed) = s.edit(label, |d: &mut Document, sel| {
let (mut all, mut removed) = (vec![], 0);
for (id, pieces) in plan {
removed += pieces.is_empty() as usize;
all.extend(replace_with_pieces(d, id, pieces)?);
}
sel.set(all.iter().copied());
Ok((all, removed))
})?;
let mut out = ids_json(&ids);
out["removed"] = json!(removed);
Ok(out)
}
/// Bounds of `pd` grown a little (the reference box of the half-planes).
fn reach(pd: &PathData) -> Rect {
pd.control_bounds().unwrap_or_default().inflate(1.0, 1.0)
}
/// `pd ∩ region`, dropping slivers.
fn intersect(pd: &PathData, rule: FillRule, region: &PathData) -> PathData {
let r = po::boolean(pd, rule, region, FillRule::NonZero, BoolOp::Intersect);
if po::area(&r, FillRule::NonZero) <= 1e-6 { PathData::default() } else { r }
}
// ---------- Line Cut ----------
fn line_cut(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.lineCut";
let axis = Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?;
run_cut(s, C, "Line Cut", &|pd, rule| split_by_line(pd, rule, &axis))
}
/// `pd` split by the line: the pieces on both sides, `None` when the line misses it.
fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option<Vec<PathData>> {
if pd.is_closed() {
let around = reach(pd);
let a = intersect(pd, rule, &axis.half_plane(1.0, around));
let b = intersect(pd, rule, &axis.half_plane(-1.0, around));
if a.is_empty() || b.is_empty() {
return None;
}
return Some(components(&a).into_iter().chain(components(&b)).collect());
}
// Open paths: the parts on each side (each pass removes the other side).
let a = erase_outline(pd, &|q| axis.side(q) < 0.0)?;
let b = erase_outline(pd, &|q| axis.side(q) >= 0.0)?;
Some(if pd.subpaths.len() == 1 {
a.into_iter().chain(b).collect()
} else {
vec![PathData::new(a.into_iter().chain(b).flat_map(|p| p.subpaths).collect())]
})
}
// ---------- Rectangle Cut ----------
fn rect_cut(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.rectCut";
let rect = match p.get("rect") {
Some(v) => {
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let [x, y, w, h] = a[..] else { return Err(bad(C, "rect must be [x, y, width, height]")) };
Rect::new(x, y, x + w, y + h).abs()
}
None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?),
};
let finite = [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
if !finite || rect.width() <= 1e-9 || rect.height() <= 1e-9 {
return Err(bad(C, "the rectangle must have a width and a height"));
}
run_cut(s, C, "Rectangle Cut", &|pd, rule| crop(pd, rule, rect))
}
/// `pd` cropped to `rect`: `None` when it lies inside, no pieces when it lies outside.
fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option<Vec<PathData>> {
let b = pd.bounds()?;
if rect.inflate(1e-9, 1e-9).contains_rect(b) {
return None;
}
if pd.is_closed() {
let r =
intersect(pd, rule, &polygon(&[rect.origin(), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)]));
return Some(if r.is_empty() { vec![] } else { vec![r] });
}
let inside = rect.inflate(1e-9, 1e-9);
let pieces = erase_outline(pd, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?;
Some(if pd.subpaths.len() == 1 || pieces.is_empty() {
pieces
} else {
vec![PathData::new(pieces.into_iter().flat_map(|p| p.subpaths).collect())]
})
}
// ---------- Mirror & Cut ----------
fn mirror_cut(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.mirrorCut";
let kind = str_param(p, "axis").unwrap_or("vertical");
let at = || -> Result<Point> {
match p.get("from") {
Some(_) => point_req(p, "from", C),
None => {
let roots = selected_roots(s)?;
let b = s.doc()?.doc.bounds_of(&roots, false).ok_or_else(|| bad(C, "the selection has no bounds"))?;
Ok(b.center())
}
}
};
let axis = match kind {
"vertical" => {
let a = at()?;
Axis { p: a, d: Vec2::new(0.0, 1.0) }
}
"horizontal" => {
let a = at()?;
Axis { p: a, d: Vec2::new(1.0, 0.0) }
}
"free" => Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?,
other => return Err(bad(C, format!("unknown axis `{other}` (vertical|horizontal|free)"))),
};
let sign = keep_sign(&axis, str_param(p, "keep")).map_err(|e| bad(C, e))?;
run_cut(s, C, "Mirror & Cut", &|pd, rule| Some(mirror(pd, rule, &axis, sign)))
}
/// The sign of [`Axis::side`] on the side `keep` names (default: left, or top when the axis runs
/// more across than down).
fn keep_sign(axis: &Axis, keep: Option<&str>) -> std::result::Result<f64, String> {
let keep = keep.unwrap_or(if axis.d.x.abs() > axis.d.y.abs() { "top" } else { "left" });
let toward = match keep {
"left" => Vec2::new(-1.0, 0.0),
"right" => Vec2::new(1.0, 0.0),
"top" => Vec2::new(0.0, -1.0),
"bottom" => Vec2::new(0.0, 1.0),
other => return Err(format!("unknown side `{other}` (left|right|top|bottom)")),
};
let s = axis.d.cross(toward);
if s.abs() < 1e-6 {
return Err(format!("`{keep}` is not a side of this axis"));
}
Ok(s.signum())
}
/// The kept half of `pd` and its reflection, joined (no pieces when nothing is on the kept side).
fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec<PathData> {
let m = axis.reflection();
if pd.is_closed() {
let kept = intersect(pd, rule, &axis.half_plane(sign, reach(pd)));
if kept.is_empty() {
return vec![];
}
let joined = po::boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union);
return if joined.is_empty() { vec![] } else { vec![joined] };
}
let kept = match erase_outline(pd, &|q| axis.side(q) * sign < 0.0) {
Some(pieces) => pieces.into_iter().flat_map(|p| p.subpaths).collect(),
None => pd.subpaths.clone(),
};
let subs: Vec<SubPath> = kept.iter().flat_map(|sp| mirror_open(sp, axis, m)).collect();
if subs.is_empty() { vec![] } else { vec![PathData::new(subs)] }
}
/// An open run and its reflection, joined where its ends touch the axis (closed when both do);
/// the two apart when neither does.
fn mirror_open(sp: &SubPath, axis: &Axis, m: Affine) -> Vec<SubPath> {
let on = |a: Option<&Anchor>| a.is_some_and(|a| axis.side(a.p).abs() <= AXIS_TOL);
let (start_on, end_on) = (on(sp.anchors.first()), on(sp.anchors.last()));
if sp.closed || sp.anchors.len() < 2 || !(start_on || end_on) {
return vec![sp.clone(), SubPath::new(sp.anchors.iter().map(|a| a.transform(m)).collect(), sp.closed)];
}
// The run ends on the axis; the reflection comes back from there.
let mut run = sp.clone();
if !end_on {
run.reverse();
}
let Some((last, body)) = run.anchors.split_last() else { return vec![] };
let q = axis.project(last.p);
let mut anchors = body.to_vec();
anchors.push(Anchor::with_handles(q, last.h_in, m * last.h_in));
anchors.extend(body.iter().rev().map(|a| Anchor::with_handles(m * a.p, m * a.h_out, m * a.h_in)));
let closed = start_on && end_on && anchors.len() > 2;
if closed && let (Some(first), Some(back)) = (anchors.first().copied(), anchors.pop()) {
// The start and its reflection meet on the axis: one closed shape.
if let Some(f) = anchors.first_mut() {
*f = Anchor::with_handles(axis.project(first.p), back.h_in, first.h_out);
}
}
vec![SubPath::new(anchors, closed)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reflection_maps_across_the_axis() {
let a = Axis::through(Point::new(0.0, 0.0), Point::new(10.0, 10.0)).unwrap();
let q = a.reflection() * Point::new(5.0, 0.0);
assert!((q.x - 0.0).abs() < 1e-9 && (q.y - 5.0).abs() < 1e-9, "{q:?}");
let v = Axis { p: Point::new(100.0, 0.0), d: Vec2::new(0.0, 1.0) };
let q = v.reflection() * Point::new(90.0, 7.0);
assert!((q.x - 110.0).abs() < 1e-9 && (q.y - 7.0).abs() < 1e-9);
}
#[test]
fn keep_names_a_side_of_the_axis() {
let v = Axis { p: Point::ZERO, d: Vec2::new(0.0, 1.0) };
let left = keep_sign(&v, Some("left")).unwrap();
assert!(v.side(Point::new(-5.0, 0.0)) * left > 0.0);
assert_eq!(keep_sign(&v, None).unwrap(), left);
assert!(keep_sign(&v, Some("top")).is_err());
let h = Axis { p: Point::ZERO, d: Vec2::new(1.0, 0.0) };
let top = keep_sign(&h, None).unwrap();
assert!(h.side(Point::new(0.0, -5.0)) * top > 0.0);
}
#[test]
fn open_half_mirrors_into_a_closed_shape() {
// An arch from the axis and back to it: its reflection closes it.
let axis = Axis { p: Point::new(50.0, 0.0), d: Vec2::new(0.0, 1.0) };
let sp = SubPath::new(
vec![Anchor::corner(Point::new(50.0, 0.0)), Anchor::corner(Point::new(10.0, 50.0)), Anchor::corner(Point::new(50.0, 100.0))],
false,
);
let out = mirror_open(&sp, &axis, axis.reflection());
assert_eq!(out.len(), 1);
assert!(out[0].closed);
assert_eq!(out[0].anchors.len(), 4);
assert!(out[0].anchors.iter().any(|a| (a.p.x - 90.0).abs() < 1e-9));
// One end on the axis: one open path through it.
let sp = SubPath::new(vec![Anchor::corner(Point::new(10.0, 0.0)), Anchor::corner(Point::new(50.0, 40.0))], false);
let out = mirror_open(&sp, &axis, axis.reflection());
assert_eq!((out.len(), out[0].closed, out[0].anchors.len()), (1, false, 3));
}
}
+663 -109
View File
@@ -7,13 +7,13 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, NodeId, NodeKind, WidthProfile};
use vectorcraft_geom::Point;
use vectorcraft_tools::distort::liquify::{LiquifyParams, apply_stroke, dabs};
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
use vectorcraft_tools::distort::{arap, collect_points, mesh_for, warp_node_with};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PuppetPin, WidthProfile};
use vectorcraft_geom::{Affine, Homography, PathData, Point, Rect};
use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample, reach_bounds};
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
use super::edit::selected_roots;
use super::edit::{duplicate_in, selected_roots};
use super::*;
use crate::EngineError;
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Liquify",
[],
None,
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y]…] (the brush stroke), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), detail? (1..10), simplify? (0..100), rate? (twirl °), complexity?, horizontal?, vertical?, affectAnchors?, affectIn?, affectOut?, ids? (default: selection, else every path the brush touches)}",
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify? (0..100) and simplifyOn? (default true; warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids? (default: selection, else every path the brush touches)}",
has_doc,
liquify
),
@@ -69,16 +69,25 @@ pub fn specs() -> Vec<CommandSpec> {
"Puppet Warp",
[],
None,
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), expand?: pt} as-rigid-as-possible mesh warp of anchors and handles",
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
has_doc,
puppet_warp
),
cmd!(
"object.puppetWarp.pins",
"Puppet Warp Pins",
[],
None,
"{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp",
has_doc,
puppet_pins
),
cmd!(
"perspective.grid.set",
"Define Perspective Grid",
[],
None,
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?} merge into the document's grid",
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?, leftOffset?, rightOffset?, groundOffset?: pt (planes moved along their normals), reproject?: bool} merge into the document's grid. Objects in perspective stay where they are, as in the reference app; with reproject they move with the grid (each keeps its place on its plane)",
has_doc,
grid_set
),
@@ -87,9 +96,9 @@ pub fn specs() -> Vec<CommandSpec> {
"Perspective Grid Preset",
[],
None,
"{kind: 1|2|3} reset the grid to the one/two/three-point preset for the first artboard (and show it)",
"{kind: 1|2|3 (its normal view) | name: a preset (see perspective.presets.list)} reset the grid to the preset fitted to the first artboard (and show it; attached objects stay attached)",
has_doc,
grid_preset
super::perspgrid::grid_preset
),
cmd!(
"perspective.grid.show",
@@ -124,7 +133,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move in Perspective",
[],
None,
"{ids?, from: [x,y], to: [x,y], plane?} slide objects within their plane (unattached objects attach to `plane`/the active plane first)",
"{ids?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it",
has_doc,
persp_move
),
@@ -133,10 +142,55 @@ pub fn specs() -> Vec<CommandSpec> {
"Draw in Perspective",
[],
None,
"{command: shape.* id, params, plane?} run a shape command and attach the result to the plane",
"{command: shape.* id, params, plane?, snap?: bool (default: Snap to Grid)} run a shape command and attach the result to the plane (snapping its corners to gridlines within a quarter cell)",
has_doc,
persp_draw
),
cmd!(
"perspective.transform",
"Transform in Perspective",
[],
None,
"{ids?, matrix: [a,b,c,d,e,f] (affine map in plane coordinates, points: u along the plane, v up or away), depth?: pt to move along the plane's normal (default 0), copy?: bool, plane?} transform objects within their own planes (Perspective Selection tool handles; unattached objects attach to `plane`/the active plane first); Transform Again repeats it → {ids}",
has_doc,
persp_transform
),
cmd!(
"perspective.nudge",
"Nudge in Perspective",
[],
None,
"{dx, dy: arrow direction (-1, 0 or 1), big?: bool (×10), copy?: bool} move the selection in perspective by the keyboard increment, as the arrow keys do with the Perspective Selection tool → {ids}",
has_selection,
persp_nudge
),
cmd!(
"perspective.plane.move",
"Move Plane",
[],
None,
"{plane?: left|right|ground (default: the active plane), offset?: pt (where along its normal; 0 is its place in the grid's definition) | by?: pt, objects?: none|move|copy (default none: the objects on the plane stay; move: they move with it, as Shift-dragging a plane widget does; copy: copies of them do, as Alt-dragging does)} → {plane, offset, ids}",
has_doc,
plane_move
),
cmd!(
"perspective.plane.matchObject",
"Move Plane to Match Object",
[],
None,
"{id? (default: the first selected object)} move the plane the object is attached to onto the object and make it the active plane → {plane, offset}",
has_doc,
plane_match
),
cmd!(
"perspective.editText",
"Edit Text",
[],
None,
"{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again → {id}",
has_doc,
edit_text
),
]
}
@@ -149,10 +203,16 @@ fn uniform() -> WidthProfile {
/// Width points closer than this (in t) are at the same place: a discontinuous point.
const SAME_T: f64 = 1e-6;
/// The weighted stroke of `id` that width point commands edit.
fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
/// The stroke of `id` that width point commands edit (a compound path's for its members).
fn stroke_mut(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
let id = stroke_owner(d, id);
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?;
n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))
}
/// [`stroke_mut`], which must have a weight.
fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> {
let st = stroke_mut(d, id)?;
if st.width <= 0.0 {
return Err(EngineError::Other("the stroke has no weight".into()));
}
@@ -271,8 +331,7 @@ fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
indices.sort_unstable();
indices.dedup();
s.edit("Delete Width Point", |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?;
let st = stroke_mut(d, id)?;
let fits = |pr: &&mut WidthProfile| indices.last().is_some_and(|i| *i < pr.points.len());
let prof = st.profile.as_mut().filter(fits).ok_or_else(|| bad(C, "no such width point"))?;
for i in indices.iter().rev() {
@@ -306,7 +365,10 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad(C, "points must be an array or null")),
};
let ids = targets(s, p)?;
let doc = &s.doc()?.doc;
let mut ids: Vec<NodeId> = targets(s, p)?.into_iter().map(|id| stroke_owner(doc, id)).collect();
ids.sort_unstable();
ids.dedup();
s.edit("Width Profile", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
@@ -325,90 +387,278 @@ fn points_of(p: &Value, key: &str) -> Option<Vec<Point>> {
p.get(key)?.as_array()?.iter().map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect()
}
/// Leaf paths under `roots` that can be edited.
fn leaf_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for r in roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. }) && !out.contains(&c.id) {
out.push(c.id);
}
});
/// A stroke's `[[x, y, pressure?]…]` samples (pressure 0..1, default 1).
fn samples_of(p: &Value, key: &str) -> Option<Vec<Sample>> {
p.get(key)?
.as_array()?
.iter()
.map(|q| Some((Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?), q.get(2).map_or(Some(1.0), Value::as_f64)?)))
.collect()
}
/// What a liquify stroke may reach: the paths it can edit, each with the box no dab outside
/// changes it ([`reach_bounds`]), and the objects it leaves as they are (type, symbols, images,
/// graphs, meshes, and envelopes, repeats and blends with their contents), each with its bounds
/// and what it is. Hidden and locked objects, and guides, are neither.
#[derive(Default)]
struct Targets {
paths: Vec<(NodeId, Rect)>,
blocked: Vec<(NodeId, Rect, &'static str)>,
/// Every object listed (selected roots may hold each other).
seen: std::collections::HashSet<NodeId>,
}
/// What `n` is when Liquify can't distort it (and doesn't look inside it).
fn not_liquified(n: &Node) -> Option<&'static str> {
Some(match &n.kind {
NodeKind::Group { .. } if n.graph.is_some() => "graphs",
NodeKind::Text(_) => "type",
NodeKind::SymbolInstance { .. } => "symbols",
NodeKind::Image(_) => "images",
NodeKind::Mesh(_) => "meshes",
NodeKind::Envelope { .. } => "envelopes",
NodeKind::Repeat(_) => "repeats",
NodeKind::Blend { .. } => "blends",
_ => return None,
})
}
impl Targets {
/// The targets under `roots` (the selection), or with none every visible object.
fn of(d: &Document, roots: &[NodeId]) -> Self {
let mut t = Self::default();
if roots.is_empty() {
d.layers.iter().for_each(|l| t.visit(l));
return t;
}
for r in roots {
let (Some(n), Some(chain)) = (d.node(*r), d.ancestry(*r)) else { continue };
if !d.is_editable(*r) {
continue;
}
// Inside a graph, an envelope, a repeat or a blend: that object is what is left alone.
match chain.iter().filter_map(|a| d.node(*a)).find_map(|a| Some((a, not_liquified(a)?))) {
Some((a, what)) => t.block(a, what),
None => t.visit(n),
}
}
t
}
fn block(&mut self, n: &Node, what: &'static str) {
if let Some(b) = n.geometric_bounds()
&& self.seen.insert(n.id)
{
self.blocked.push((n.id, b, what));
}
}
out.retain(|id| d.is_editable(*id));
out
fn visit(&mut self, n: &Node) {
if !n.visible || n.locked {
return;
}
if let Some(what) = not_liquified(n) {
return self.block(n, what);
}
match &n.kind {
NodeKind::Path { guide: false, path, .. } => {
if let Some(b) = reach_bounds(path)
&& self.seen.insert(n.id)
{
self.paths.push((n.id, b));
}
}
_ => n.children().into_iter().flatten().for_each(|c| self.visit(c)),
}
}
}
/// Does a dab with box `bb` reach `b`?
fn reaches(bb: Rect, b: Rect) -> bool {
b.inflate(1e-6, 1e-6).intersect(bb).area() > 0.0
}
/// A liquify stroke being applied: its dabs and the paths they reached so far, so a longer stroke
/// (the next sample of a drag) applies only the dabs it adds. It belongs to one document snapshot
/// (`base`, held so the pointer can't be reused) and one set of other parameters (`key`).
pub(crate) struct LiquifyStroke {
base: Arc<Document>,
key: Value,
prm: LiquifyParams,
dabs: Dabber,
targets: Targets,
/// The paths reached, by their index in `targets.paths`.
paths: Vec<(usize, PathStroke)>,
/// The objects left alone that the brush passed over, by their index in `targets.blocked`.
skipped: Vec<usize>,
/// Which of `targets.paths` and `targets.blocked` the brush reached.
reached: (Vec<bool>, Vec<bool>),
}
impl LiquifyStroke {
fn new(base: Arc<Document>, key: Value, prm: LiquifyParams, roots: &[NodeId]) -> Self {
let targets = Targets::of(&base, roots);
let reached = (vec![false; targets.paths.len()], vec![false; targets.blocked.len()]);
Self { base, key, prm, dabs: Dabber::new(&prm), targets, paths: vec![], skipped: vec![], reached }
}
/// Start the targets dab box `bb` reaches.
fn reach(&mut self, bb: Rect) {
for (i, (id, b)) in self.targets.paths.iter().enumerate() {
if let Some(r) = self.reached.0.get_mut(i).filter(|r| !**r && reaches(bb, *b)) {
*r = true;
let Some(path) = self.base.node(*id).and_then(|n| n.path_data()) else { continue };
self.paths.push((i, PathStroke::new(path.clone(), id.0)));
}
}
for (i, (_, b, _)) in self.targets.blocked.iter().enumerate() {
if let Some(r) = self.reached.1.get_mut(i).filter(|r| !**r && reaches(bb, *b)) {
*r = true;
self.skipped.push(i);
}
}
}
/// Add `samples` to the stroke and apply the dabs they make.
fn extend(&mut self, samples: &[Sample]) {
let from = self.dabs.dabs.len();
for s in samples {
self.dabs.push(*s);
}
for i in from..self.dabs.dabs.len() {
if let Some(c) = self.dabs.dabs.get(i).map(|d| d.c) {
self.reach(self.prm.brush_bounds(c));
}
}
if let Some(t) = self.dabs.tail() {
self.reach(self.prm.brush_bounds(t.c));
}
for (_, ps) in &mut self.paths {
ps.advance(&self.dabs.dabs, &self.prm);
}
}
/// The paths the stroke changed (in document order) and their new geometry.
fn results(&self) -> Vec<(NodeId, PathData)> {
let tail = self.dabs.tail();
let mut out: Vec<(usize, NodeId, PathData)> =
self.paths.iter().filter_map(|(i, ps)| Some((*i, self.targets.paths.get(*i)?.0, ps.finish(&self.dabs.dabs, tail, &self.prm)?))).collect();
out.sort_by_key(|(i, ..)| *i);
out.into_iter().map(|(_, id, p)| (id, p)).collect()
}
/// The objects under the brush left as they are, and the status message about them.
fn skipped(&self) -> (Vec<u64>, Option<String>) {
let mut ids = vec![];
let mut kinds: Vec<&str> = vec![];
for (id, _, what) in self.skipped.iter().filter_map(|i| self.targets.blocked.get(*i)) {
ids.push(id.0);
if !kinds.contains(what) {
kinds.push(what);
}
}
let msg = (!ids.is_empty()).then(|| {
let n = ids.len();
format!(
"{} left {n} object{} under the brush as {} ({}): it reshapes paths only",
self.prm.kind.label(),
if n == 1 { "" } else { "s" },
if n == 1 { "it was" } else { "they were" },
kinds.join(", ")
)
});
(ids, msg)
}
}
fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.liquify";
let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?;
let pts = points_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y]…] list"))?;
if pts.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) {
let pts = samples_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y,pressure?]…] list"))?;
if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) {
return Err(bad(C, "points must be finite"));
}
let dab_pts = dabs(&pts, prm.dab_spacing());
let roots = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => v,
None => s.doc()?.selection.objects.clone(),
};
let doc = &s.doc()?.doc;
let leaves = if roots.is_empty() {
// Nothing selected: everything the brush sweeps over.
// No dabs sweep nothing.
let sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap_or(vectorcraft_geom::Rect::ZERO);
let mut all = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { guide: false, .. })
&& n.geometric_bounds().is_some_and(|b| b.inflate(1e-6, 1e-6).intersect(sweep).area() > 0.0)
{
all.push(n.id);
}
});
all.retain(|id| doc.is_editable(*id) && doc.is_visible(*id));
all
} else {
leaf_paths(doc, &roots)
let base = s.doc()?.doc.clone();
let mut key = p.clone();
if let Some(o) = key.as_object_mut() {
o.remove("points");
o.insert("__roots".into(), json!(roots.iter().map(|r| r.0).collect::<Vec<_>>()));
}
// A live drag: the same stroke as the last preview with more samples goes on from there.
let mut stroke = match s.liquify_stroke.take() {
Some(st) if Arc::ptr_eq(&st.base, &base) && st.key == key && pts.starts_with(st.dabs.samples()) => st,
_ => Box::new(LiquifyStroke::new(base, key, prm, &roots)),
};
let done = stroke.dabs.samples().len();
stroke.extend(pts.get(done..).unwrap_or_default());
let results = stroke.results();
let (skipped, warning) = stroke.skipped();
let changed = s.edit(&format!("{} Tool", prm.kind.label()), |d, _| {
let mut changed = vec![];
for id in &leaves {
let Some(n) = d.node_mut(*id) else { continue };
if let NodeKind::Path { path, live, .. } = &mut n.kind
&& apply_stroke(path, &dab_pts, &prm, id.0)
{
for (id, new) in results {
let Some(n) = d.node_mut(id) else { continue };
if let NodeKind::Path { path, live, .. } = &mut n.kind {
*path = new;
*live = None;
changed.push(id.0);
}
}
Ok(changed)
})?;
Ok(json!({ "ids": changed }))
if s.in_interaction() {
s.liquify_stroke = Some(stroke);
}
let mut out = json!({ "ids": changed, "skipped": skipped });
if let Some(w) = warning {
out["warning"] = json!(w);
}
Ok(out)
}
// ---------- puppet warp ----------
/// The objects a Puppet Warp command works on: `id`/`ids`, else `default`.
fn puppet_ids(p: &Value, default: impl FnOnce() -> Result<Vec<NodeId>>) -> Result<Vec<NodeId>> {
let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => v,
None => default()?,
};
if ids.is_empty() {
return Err(EngineError::Other("select the artwork to warp".into()));
}
Ok(ids)
}
fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.puppetWarp";
let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?;
let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?;
if pins.is_empty() || pins.len() != moved.len() {
let rest = bool_or(p, "rest", false);
if (pins.is_empty() && !rest) || pins.len() != moved.len() {
return Err(bad(C, "pins and moved must be non-empty lists of the same length"));
}
if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) {
return Err(bad(C, "pins must be finite"));
}
let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
Some(v) => v,
None => selected_roots(s)?,
};
if ids.is_empty() {
return Err(EngineError::Other("select the artwork to warp".into()));
}
let ids = if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { puppet_ids(p, || selected_roots(s))? };
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let angles: Vec<Option<f64>> = match p.get("angles") {
None | Some(Value::Null) => vec![None; pins.len()],
Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(),
Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")),
};
let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() });
if rest {
let pins: Vec<PuppetPin> = pins.iter().zip(&moved).zip(angles).map(|((r, a), angle)| PuppetPin { rest: *r, at: *a, angle }).collect();
s.edit("Puppet Warp", |d, _| warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e)))?;
return Ok(ids_json);
}
let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
let pins: Vec<arap::Pin> = pins.iter().zip(&moved).map(|(a, b)| arap::Pin { rest: *a, target: *b }).collect();
let pins: Vec<arap::Pin> = pins.iter().zip(&moved).zip(angles).map(|((a, b), angle)| arap::Pin { angle, ..arap::Pin::new(*a, *b) }).collect();
let deformed = arap::deform(&mesh, &pins);
let f = |q: Point| mesh.map(&deformed, q);
s.edit("Puppet Warp", |d, _| {
@@ -418,7 +668,26 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(ids_json)
}
/// Forget the Puppet Warp pins: not an undo step, and a saved document stays saved (pins are
/// never saved).
pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
let clean = !st.is_dirty();
Arc::make_mut(&mut st.doc).puppet = None;
if clean {
st.mark_saved();
}
}
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
let ids = puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))?;
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
let set = PinSet::of(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?;
let mut v = set.params(&set.pins, expand);
v["auto"] = json!(set.auto);
Ok(v)
}
// ---------- perspective grid ----------
@@ -434,7 +703,7 @@ pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) {
}
/// Change view state of the grid without an undo step.
fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
let st = s.doc_mut()?;
let mut g = grid_of(&st.doc);
f(&mut g);
@@ -447,24 +716,19 @@ fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<Persp
}
fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
let g = grid_of(&s.doc()?.doc).merged(p).map_err(|e| bad("perspective.grid.set", e))?;
s.edit("Define Perspective Grid", |d, _| {
store_grid(d, &g);
Ok(())
})?;
Ok(g.definition_json())
}
fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
let kind = p
.get("kind")
.and_then(Value::as_u64)
.filter(|k| (1..=3).contains(k))
.ok_or_else(|| bad("perspective.grid.preset", "kind must be 1, 2 or 3"))? as u8;
let old = grid_of(&s.doc()?.doc);
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
let g = PerspectiveGrid { attached: old.attached, ..PerspectiveGrid::preset(kind, ab) };
s.edit("Perspective Grid Preset", |d, _| {
let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?;
let reproject = bool_or(p, "reproject", false);
s.edit("Define Perspective Grid", |d, _| {
if reproject {
old.adopt_stored_attachments(d);
for (id, plane, depth) in persp::attached_roots(d) {
let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth));
let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?;
let m = h.then_after(&hi);
warp_checked(d, id, &m)?;
}
}
store_grid(d, &g);
Ok(())
})?;
@@ -502,21 +766,27 @@ fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
}
/// Map `n` with `f`, failing if any point would leave the plane's visible side.
fn warp_checked(d: &mut Document, id: NodeId, f: &dyn Fn(Point) -> Option<Point>) -> Result<()> {
/// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it),
/// failing if any point would leave the plane's visible side.
fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> Result<()> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let mut pts = vec![];
collect_points(n, &mut pts);
if pts.iter().any(|q| f(*q).is_none()) {
if pts.iter().any(|q| h.apply(*q).is_none()) {
return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into()));
}
warp_node_with(n, &|q| f(q).unwrap_or(q));
project_node(n, h);
Ok(())
}
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane) -> Result<()> {
/// Attach `id` to `plane`: projected by `map`, else by the map that keeps its bounds' corners
/// (on the nearest gridlines with `snap`).
fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool, map: Option<Homography>) -> Result<()> {
let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?;
let m = g.attach_map(plane, b).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
let m =
map.or_else(|| g.attach_homography(plane, b, snap)).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?;
warp_checked(d, id, &m)?;
persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, g.offset(plane));
g.attached.insert(id.0.to_string(), plane);
Ok(())
}
@@ -527,7 +797,7 @@ fn attach(s: &mut Session, p: &Value) -> Result<Value> {
let plane = plane_param(&g, p, "perspective.attach")?;
s.edit("Attach to Active Plane", |d, _| {
for id in &ids {
attach_in(d, &mut g, *id, plane)?;
attach_in(d, &mut g, *id, plane, false, None)?;
}
store_grid(d, &g);
Ok(())
@@ -541,6 +811,9 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Release with Perspective", |d, _| {
for id in &ids {
g.attached.remove(&id.0.to_string());
if let Some(n) = d.node_mut(*id) {
persp::release(n);
}
}
store_grid(d, &g);
Ok(())
@@ -548,30 +821,230 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and
/// the depth it ends at.
type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>;
/// Where an object is in perspective: its plane and depth.
type Attached = (Plane, f64);
fn beyond_horizon() -> EngineError {
EngineError::Other("the pointer is beyond the plane's horizon".into())
}
/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay
/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach
/// to `fallback` first. → the edited objects and where the first one was (plane, depth).
fn persp_edit(
s: &mut Session,
label: &str,
ids: &[NodeId],
fallback: Option<Plane>,
copy: bool,
step: PerspStep,
) -> Result<(Vec<NodeId>, Option<Attached>)> {
if ids.is_empty() {
return Err(EngineError::Other("select the objects to transform in perspective".into()));
}
let mut g = grid_of(&s.doc()?.doc);
s.edit(label, |d, sel| {
g.adopt_stored_attachments(d);
let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() };
let mut first = None;
for id in &targets {
let (plane, depth) = match g.attachment_of(d, *id) {
Some(a) => a,
None => {
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
attach_in(d, &mut g, *id, pl, false, None)?;
(pl, g.offset(pl))
}
};
first.get_or_insert((plane, depth));
let (h, depth) = step(&g, plane, depth)?;
warp_checked(d, *id, &h)?;
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth);
}
store_grid(d, &g);
Ok((targets, first))
})
}
/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once
/// the drag that made it is committed.
fn record_again(s: &mut Session, again: Value) -> Result<()> {
let st = s.doc_mut()?;
match &mut st.interaction {
Some(it) => it.perspective_again = Some(again),
None => st.last_perspective = Some(again),
}
Ok(())
}
fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// A plane-space affine map as `perspective.transform`'s `matrix` param.
fn matrix_json(m: Affine) -> Value {
json!(m.as_coeffs())
}
fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.move";
let ids = roots(s, p)?;
let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?;
let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?;
let mut g = grid_of(&s.doc()?.doc);
let fallback = plane_param(&g, p, C).ok();
s.edit("Move in Perspective", |d, _| {
for id in &ids {
let plane = match g.attached_plane(*id) {
Some(pl) => pl,
None => {
let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?;
attach_in(d, &mut g, *id, pl)?;
pl
}
};
let m = g.move_map(plane, from, to).ok_or_else(|| EngineError::Other("the pointer is beyond the plane's horizon".into()))?;
warp_checked(d, *id, &m)?;
if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) {
return Err(bad(C, "from and to must be finite"));
}
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false));
// Snap to Grid: a correction for the objects on the first one's plane and depth.
let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) };
let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| {
dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2)
};
// In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches.
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
if perp {
let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?;
Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth))
} else {
let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?);
Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth))
}
};
let label = if copy { "Copy in Perspective" } else { "Move in Perspective" };
let (targets, first) = persp_edit(s, label, &ids, fallback, copy, &step)?;
// Transform Again repeats the first object's move.
if let Some((pl, depth)) = first {
let g = grid_of(&s.doc()?.doc);
let again = match perp {
true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})),
false => {
g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy}))
}
};
if let Some(a) = again {
record_again(s, a)?;
}
}
Ok(ids_json(&targets))
}
fn persp_transform(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.transform";
let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?;
let dz = f64_or(p, "depth", 0.0);
if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() {
return Err(bad(C, "matrix must be finite and invertible, depth finite"));
}
let ids = roots(s, p)?;
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
let copy = bool_or(p, "copy", false);
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
let to = depth + dz;
if to.abs() > persp::MAX_DEPTH {
return Err(EngineError::Other("the objects would leave the grid".into()));
}
Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to))
};
let (targets, _) = persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?;
record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?;
Ok(ids_json(&targets))
}
/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space
/// transform on the selection.
pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result<Value> {
let mut p = again.clone();
if let Some(o) = p.as_object_mut() {
o.remove("ids");
}
persp_transform(s, &p)
}
fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.plane.move";
let old = grid_of(&s.doc()?.doc);
let plane = plane_param(&old, p, C)?;
let from = old.offset(plane);
let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) {
(Some(o), _) => o,
(None, Some(b)) => from + b,
(None, None) => return Err(bad(C, "give offset or by")),
};
let objects = str_param(p, "objects").unwrap_or("none");
if !matches!(objects, "none" | "move" | "copy") {
return Err(bad(C, "objects must be none, move or copy"));
}
let mut g = old.clone();
g.set_offset(plane, to);
g.validate().map_err(|e| bad(C, e))?;
let label = match objects {
"copy" => "Move Plane and Copy Objects",
"move" => "Move Plane and Objects",
_ => "Move Plane",
};
let ids = s.edit(label, |d, sel| {
old.adopt_stored_attachments(d);
let mut ids = vec![];
if objects != "none" {
// The objects on the plane where it was (not those moved off it along its normal).
let on: Vec<NodeId> = persp::attached_roots(d)
.into_iter()
.filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id))
.map(|(id, ..)| id)
.collect();
ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on };
let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?;
for id in &ids {
warp_checked(d, *id, &m)?;
persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to);
}
}
store_grid(d, &g);
Ok(ids)
})?;
Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = id_param(p, "id")
.or_else(|| st.selection.objects.first().copied())
.ok_or_else(|| EngineError::Other("select an object in perspective".into()))?;
let mut g = grid_of(&st.doc);
let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?;
g.set_offset(plane, depth);
g.plane = plane;
g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?;
s.edit("Move Plane to Match Object", |d, _| {
store_grid(d, &g);
Ok(())
})?;
ok()
Ok(json!({ "plane": plane.id(), "offset": depth }))
}
fn persp_nudge(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "perspective.nudge";
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) {
return Err(bad(C, "dx and dy give the arrow's direction"));
}
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let ids = selected_roots(s)?;
// The step is measured on the page at the centre of the selection's perspective box.
let st = s.doc()?;
let g = grid_of(&st.doc);
let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?;
let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?;
let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k;
persp_move(
s,
&json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}),
)
}
fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
@@ -580,11 +1053,17 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let mut g = grid_of(&s.doc()?.doc);
let plane = plane_param(&g, p, C)?;
let snap = bool_or(p, "snap", g.snap);
// A click-to-size dialog's shape keeps its sizes in plane units from the click.
let map = match point_param(p, "at") {
Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?),
None => None,
};
let undo_before = s.doc()?.history.undo.len();
let r = s.execute(&command, &params)?;
let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?;
let res = s.edit("Draw in Perspective", |d, _| {
attach_in(d, &mut g, id, plane)?;
attach_in(d, &mut g, id, plane, snap, map)?;
store_grid(d, &g);
Ok(())
});
@@ -602,10 +1081,85 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
/// Shape tool previews become `perspective.draw` while the grid is shown with an active plane.
pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> {
const SHAPES: &[&str] = &["shape.rectangle", "shape.ellipse", "shape.polygon", "shape.star", "shape.line", "shape.rectangularGrid", "shape.arc"];
const SHAPES: &[&str] = &[
"shape.rectangle",
"shape.ellipse",
"shape.polygon",
"shape.star",
"shape.line",
"shape.rectangularGrid",
"shape.arc",
"shape.flare",
"shape.spiral",
"shape.polarGrid",
];
if !SHAPES.contains(&cmd) {
return None;
}
let g = PerspectiveGrid::from_doc(&s.active()?.doc)?;
let g = PerspectiveGrid::current(&s.active()?.doc);
(g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params})))
}
/// A click-to-size shape dialog's command (`cmd` with `params`, clicked at `at`) as it runs: on the
/// active plane while the grid shows (`perspective.draw` keeping the sizes in plane units from the
/// click), else `None` (run it as it is).
pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> Option<(String, Value)> {
let (c, mut p) = perspective_rewrite(s, cmd, params)?;
p["at"] = json!([at.x, at.y]);
Some((c, p))
}
/// Object › Perspective › Edit Text: type in perspective shown flat where it is drawn, in isolation
/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]).
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id =
id_param(p, "id").or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) {
return Err(EngineError::Other("select type in perspective".into()));
}
s.edit("Edit Text", |d, _| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
let h = n.projection().ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) };
// Shown flat where it is drawn: the type moves there and the projection moves the other
// way, so the picture stays.
let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) };
let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center());
t.transform(shift);
if let Some(rec) = n.perspective.as_deref_mut() {
rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array());
rec.editing = true;
}
Ok(())
})?;
let st = s.doc_mut()?;
st.isolation = Some(id);
st.selection.set([id]);
st.revision += 1;
Ok(json!({ "id": id.0 }))
}
/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and
/// in every undo state (the flat view is never an undo step of its own).
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
let clear = |doc: &mut Arc<Document>| {
if doc.node(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing)
&& let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut())
{
p.editing = false;
}
};
let saved = Arc::ptr_eq(&st.doc, &st.saved_doc);
clear(&mut st.doc);
if saved {
st.saved_doc = st.doc.clone();
}
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
clear(&mut e.doc);
}
if let Some(it) = &mut st.interaction {
clear(&mut it.doc);
}
st.revision += 1;
}
+36 -10
View File
@@ -1,6 +1,8 @@
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
//! blob brush, smooth, path eraser, join).
use std::sync::Arc;
use kurbo::{ParamCurve, ParamCurveNearest};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
@@ -224,19 +226,43 @@ fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec<NodeId> {
out
}
/// A copy of path node `id` (fresh id, no live shape) with geometry `pd`.
/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids.
fn compound_members(d: &mut Document, pd: PathData) -> Vec<Arc<Node>> {
pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect()
}
/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members
/// are rebuilt, one per subpath).
pub(super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> {
if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) {
let members = compound_members(d, pd);
if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
*children = members;
}
return Ok(());
}
*path_mut(d, id)? = pd;
Ok(())
}
/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`.
fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result<Node> {
let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone();
n.id = d.alloc_id();
if let NodeKind::Path { path, live, .. } = &mut n.kind {
*path = pd;
*live = None;
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
*path = pd;
*live = None;
}
NodeKind::Compound { children, .. } => *children = compound_members(d, pd),
_ => {}
}
Ok(n)
}
/// Replace path `id` with `pieces` (first keeps the id, the rest go right above it). Returns ids.
fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above
/// it; none removes it). Returns ids.
pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
let mut it = pieces.into_iter();
let Some(first) = it.next() else {
d.remove(id)?;
@@ -248,16 +274,16 @@ fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> R
let n = sibling_with(d, id, pd)?;
ids.push(d.insert(parent, idx + 1 + k, n)?);
}
*path_mut(d, id)? = first;
set_geometry(d, id, first)?;
Ok(ids)
}
fn ids_json(ids: &[NodeId]) -> Value {
pub(super) fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// Split a filled path into connected pieces (each outer contour with its holes).
fn components(pd: &PathData) -> Vec<PathData> {
pub(super) fn components(pd: &PathData) -> Vec<PathData> {
let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect();
if subs.len() <= 1 {
return if pd.is_empty() { vec![] } else { vec![pd.clone()] };
@@ -382,7 +408,7 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
/// Erase from an outline path (open or closed) where `remove` holds. Returns the new geometry per
/// output object (one object per piece when the path had a single subpath), or None if unchanged.
fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
pub(super) fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
let mut changed = false;
let mut subs = vec![];
for sp in &pd.subpaths {
+4 -2
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"file.new",
"New…",
["File"],
Some("Cmd+N"),
None,
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
always,
super::newdoc::file_new
@@ -117,7 +117,9 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
s.cancel_interaction()?;
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
s.commit_interaction()?;
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
+72 -5
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
has_doc,
apply
),
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effects",
[],
None,
"{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
"{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin.<plug-in id>, menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
always,
list
),
@@ -130,6 +130,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
let gid = s.edit(&label, |d, sel| {
let gid = super::object::group_nodes(d, &loose)?;
d.node_mut(gid).ok_or(EngineError::NoNode(gid))?.appearance.effects.push(effect.clone());
sel.set([gid]);
Ok(gid)
})?;
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
}
let item = item_target(s, p, C)?;
let mut index = 0;
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
@@ -143,9 +152,23 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
}
/// The selected objects a Pathfinder effect should group before it applies: it combines the
/// contents of a group or layer, so on several loose objects (none a group) it would do nothing.
/// `None` when the effect isn't one, objects are named (`ids`) or another target is asked for.
fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<NodeId>>> {
if !effects::is_pathfinder(id) || ["ids", "id", "item", "target"].iter().any(|k| p.get(k).is_some()) {
return Ok(None);
}
let roots = super::appearance::subject_roots(s)?;
let d = &s.doc()?.doc;
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
Ok((roots.len() >= 2 && !grouped).then_some(roots))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let catalog: Vec<Value> = effects::effect_catalog()
.into_iter()
.chain(effects::plugin_effects())
.map(|e| {
json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster,
"lengths": {"always": e.lengths.always, "absolute": e.lengths.absolute}})
@@ -321,11 +344,25 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
let Some(n) = d.node(id).cloned() else { return };
let container = is_container(&n);
let Some(mut n) = d.node(id).cloned() else { return };
if !expandable(&n) {
return;
}
// Colour adjustments: the colours inside become the adjusted ones (an embedded image a
// recoloured copy), then the rest of the appearance expands.
if effects::has_adjustment(&n)
&& let Some(m) = effects::adjust_in_document(d, &n)
&& let Some(slot) = d.node_mut(id)
{
*slot = m.clone();
out.push(id);
n = m;
if !expandable(&n) {
return;
}
}
// (A symbol instance has become its art, a group.)
let container = is_container(&n);
// Crop Marks: a group of the object and its marks, then the object expands on its own.
if let Some(mut m) = effects::crop_marks_art(&n) {
let mut seen = std::collections::HashSet::from([id]);
@@ -385,7 +422,9 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush
&& let Some(slot) = d.node_mut(id)
{
*slot = m;
out.push(id);
if out.last() != Some(&id) {
out.push(id);
}
}
if container {
let children: Vec<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
@@ -490,6 +529,34 @@ mod tests {
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn pathfinder_effect_on_loose_objects_groups_them_first() {
let (mut s, a) = session_with_rect();
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
assert_eq!(r["grouped"], true);
let g = NodeId(r["ids"][0].as_u64().unwrap());
let gn = node(&s, g);
assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [a, b], "stacking order kept");
assert!(vectorcraft_render::effects::has_pathfinder(&gn));
assert_eq!(s.doc().unwrap().selection.objects, [g]);
// Live: one united shape, so expanding leaves a single path.
s.execute("effect.expandAppearance", &json!({})).unwrap();
assert_eq!(node(&s, g).children().unwrap().len(), 1);
// Grouping and applying are one undo step.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(g).is_none());
assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b));
// A selection holding a group applies to each object as before; so does a single object.
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
assert!(r.get("grouped").is_none());
assert_eq!(node(&s, a).appearance.effects.len(), 1);
}
#[test]
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
let (mut s, a) = session_with_rect();
+22 -2
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (type → outlines, live shapes → paths, effects baked), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, envelopes → their distorted content), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
has_selection,
expand
),
@@ -84,6 +84,10 @@ struct Expandable {
effects: bool,
fill: bool,
stroke: bool,
/// An envelope (Object expands it into its distorted content).
envelope: bool,
/// Live blends (Object expands them into groups of their steps).
blends: bool,
}
impl Expandable {
@@ -95,6 +99,8 @@ impl Expandable {
e.text |= matches!(c.kind, NodeKind::Text(_));
e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. });
e.effects |= !c.appearance.effects.is_empty();
e.envelope |= matches!(c.kind, NodeKind::Envelope { .. });
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
e.fill |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
@@ -106,7 +112,7 @@ impl Expandable {
}
fn object(&self) -> bool {
self.text || self.live || self.effects
self.text || self.live || self.effects || self.envelope || self.blends
}
}
@@ -129,9 +135,23 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let from = s.doc()?.history.undo.len();
let rev0 = s.doc()?.revision;
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
if object && e.blends {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| super::live::expand_blends(d, &roots).map(|_| ()))?;
}
if object && e.effects {
let _ = run_raw(s, "effect.expandAppearance", &json!({}));
}
if object && e.envelope {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| {
for r in &roots {
super::live::expand_envelopes_under(d, *r)?;
}
Ok(())
})?;
}
if object && e.live {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| {
+76 -24
View File
@@ -1,37 +1,89 @@
//! `command.batch`: several commands as one undo step.
//! `command.batch`: several commands as one undo step (in each document they edit).
use serde_json::{Value, json};
use super::super::*;
use crate::EngineError;
use crate::{DocState, EngineError};
pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
let mut cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
let label = str_param(p, "label").unwrap_or("Batch").to_string();
s.begin_interaction(&label)?;
// Session-level state a step may change (paint defaults, drawing mode) rolls back too.
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone());
let mut results = vec![];
for c in &cmds {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
if id == "command.batch" {
s.cancel_interaction()?;
return Err(bad("command.batch", "batches cannot nest"));
// What an error rolls back besides the documents: session-level state a step may change
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
s.batch_stash = Some(vec![]);
let r = run_steps(s, &label, &mut cmds);
let stash = s.batch_stash.take().unwrap_or_default();
let results = match r {
Ok(results) => results,
Err(e) => {
roll_back(s, &start, stash);
s.active = active;
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved;
s.pending_paint = None;
return Err(e);
}
let params = c.get("params").cloned().unwrap_or(json!({}));
match s.execute(id, &params) {
Ok(v) => results.push(v),
Err(e) => {
s.cancel_interaction()?;
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard) = saved;
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
}
};
// Each document the steps edited keeps them as one undo step.
s.remember_pending_paint();
for st in &mut s.docs {
if let Some(it) = st.interaction.take() {
st.keep_interaction(it);
}
}
// Commit as one undo step (the interaction's "preview" is the batch itself).
if let Some(it) = s.doc_mut()?.interaction.as_mut() {
it.preview = Some(("command.batch".into(), p.clone()));
// The batch journals itself, whichever document it ends in.
let mut entry = p.clone();
if let Some(o) = entry.as_object_mut() {
o.insert("commands".into(), Value::Array(cmds));
}
s.commit_interaction()?;
s.journal.push(("command.batch".into(), entry));
Ok(json!({ "results": results }))
}
/// Run the steps → their results. Each runs inside an interaction of the document active when it
/// starts (one for all the steps in that document), so a step may open, switch or close documents.
fn run_steps(s: &mut Session, label: &str, cmds: &mut [Value]) -> Result<Vec<Value>> {
let mut results = Vec::with_capacity(cmds.len());
for c in cmds {
let id = c.get("command").and_then(Value::as_str).unwrap_or("").to_string();
if id == "command.batch" {
return Err(bad("command.batch", "batches cannot nest"));
}
if s.active().is_some() {
s.begin_interaction(label)?;
}
let params = c.get("params").cloned().unwrap_or(json!({}));
let (v, noted) =
s.execute_step(&id, &params).map_err(|e| EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())))?;
// The step's journal entry records what it took from the preferences or the clock (a
// save's date, a new document's), as a command of its own would.
if noted != params
&& let Some(c) = c.as_object_mut()
{
c.insert("params".into(), noted);
}
results.push(v);
}
Ok(results)
}
/// The documents as they were before the batch (`start`: their uids in order): the ones it opened
/// close, the ones it closed or replaced (`stash`) come back, and each one's interaction is undone.
fn roll_back(s: &mut Session, start: &[u64], mut stash: Vec<DocState>) {
let mut open = std::mem::take(&mut s.docs);
for &uid in start {
// The newest revision the document reached, so the restored one redraws.
let newest = open.iter().chain(&stash).filter(|d| d.uid == uid).map(|d| d.revision).max().unwrap_or_default();
// Its state before the batch: the first one a step closed or replaced, else the open one.
let st = match stash.iter().position(|d| d.uid == uid) {
Some(i) => Some(stash.remove(i)),
None => open.iter().position(|d| d.uid == uid).map(|i| open.remove(i)),
};
if let Some(mut st) = st {
st.undo_interaction();
st.revision = newest.saturating_add(1);
s.docs.push(st);
}
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ struct Params {
}
/// A string or number param as text (`2018` and `"2018"` alike).
fn text(v: &Value) -> String {
pub(crate) fn text(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
+92
View File
@@ -0,0 +1,92 @@
//! Opening and placing DXF drawings: the `dxf` options `document.open` and `file.place` read
//! (layout, scale or fit, lineweights, centring, merged layers) and `document.dxfInfo` (layouts,
//! layers, units) for the DXF Import Options dialog.
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_cad::ImportOptions;
use vectorcraft_doc::{Document, Unit};
use super::super::*;
use super::{err, source};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
query "document.dxfInfo",
"DXF Info",
[],
None,
"{path} or {name?, dataBase64} what a DXF drawing holds → {version (2018, R12…), units (its drawing unit: Millimeters, Inches, Unitless…), layouts: [\"Model\", paper layouts…], layers: [name…], unit, scale (the default ratio: 1 unit = scale drawing units, the drawing at 1:1)}; a binary DXF or a DWG answers an error saying to save it as ASCII DXF",
always,
dxf_info
)]
}
/// The `dxf` params, as they come.
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct Params {
layout: Option<String>,
fit: Option<bool>,
fit_to: Option<[f64; 2]>,
unit: Option<String>,
scale: Option<f64>,
scale_lineweights: Option<bool>,
center: Option<bool>,
merge_layers: Option<bool>,
}
/// The DXF import options in `p` (the `dxf` object of `document.open` and `file.place`).
pub(super) fn options(cmd: &str, p: Option<&Value>) -> Result<ImportOptions> {
let d = ImportOptions::default();
let Some(p) = p.filter(|v| !v.is_null()) else { return Ok(d) };
if !p.is_object() {
return Err(bad(cmd, "dxf must be an object of DXF import options"));
}
let q = Params::deserialize(p).map_err(|e| bad(cmd, format!("dxf: {e}")))?;
let unit = match q.unit.as_deref() {
None => None,
Some(u) => Some(Unit::named(u).ok_or_else(|| bad(cmd, format!("dxf unit `{u}`: a unit such as mm, cm, in or pt")))?),
};
if let Some(s) = q.scale
&& !(s.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&s))
{
return Err(bad(cmd, format!("dxf scale must be a positive number up to {}, not {s}", vectorcraft_cad::SCALE_RANGE.end())));
}
let fit_to = match q.fit_to {
None => d.fit_to,
Some([w, h]) if w > 0.0 && h > 0.0 && w.max(h) <= crate::MAX_COORD => (w, h),
Some(_) => return Err(bad(cmd, "dxf fitTo must be [width, height], positive")),
};
Ok(ImportOptions {
layout: q.layout.filter(|l| !l.trim().is_empty()),
fit: q.fit.unwrap_or(d.fit),
fit_to,
unit,
scale: q.scale,
scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights),
center: q.center.unwrap_or(d.center),
merge_layers: q.merge_layers.unwrap_or(d.merge_layers),
})
}
/// Import a DXF drawing → the document and import notes.
pub(super) fn import(bytes: &[u8], o: &ImportOptions) -> Result<(Document, Vec<String>)> {
let r = vectorcraft_cad::import(bytes, o).map_err(err)?;
Ok((r.document, r.warnings))
}
const INFO: &str = "document.dxfInfo";
fn dxf_info(_: &mut Session, p: &Value) -> Result<Value> {
let bytes = source(p, INFO)?.bytes;
let i = vectorcraft_cad::info(&bytes).map_err(err)?;
Ok(json!({
"version": i.version,
"units": i.units,
"layouts": i.layouts,
"layers": i.layers,
"unit": i.unit.label(),
"scale": i.scale,
}))
}
+97 -14
View File
@@ -11,7 +11,8 @@ use vectorcraft_geom::Rect;
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction};
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat};
use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions};
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp, psd, tga};
use super::super::*;
use super::Format;
@@ -74,6 +75,9 @@ pub struct Encoded {
pub maps: Vec<imagemap::Map>,
/// The encoder's warnings (features approximated or left out, options not applied yet).
pub warnings: Vec<String>,
/// What joins the file's stem and the artboard name when there is a file per artboard
/// (default `-`; EPS uses `_`, as the reference app names them).
pub joiner: Option<&'static str>,
}
impl Encoded {
@@ -82,8 +86,9 @@ impl Encoded {
}
/// Every file to write for the destination `path` (a path or a file name): the file itself
/// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images
/// beside it under their own names, then the image maps as `{image stem}.html` or `.map`.
/// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard
/// among several, as Save's master file, is the file itself), then the linked images beside it
/// under their own names, then the image maps as `{image stem}.html` or `.map`.
pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, Cow<'a, [u8]>)> {
// Siblings keep the path's own separators (this runs on every platform and on the web).
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
@@ -91,14 +96,20 @@ impl Encoded {
let mut out: Vec<(String, Cow<[u8]>)> = match self.files.as_slice() {
[(_, bytes)] => vec![(path.to_string(), Cow::Borrowed(bytes.as_slice()))],
files => {
let boards: Vec<usize> = files.iter().map(|(b, _)| b.unwrap_or(0)).collect();
let boards: Vec<usize> = files.iter().filter_map(|(b, _)| *b).collect();
let file = std::path::Path::new(&path[dir.len()..]);
let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let ext = file.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
super::artboard_file_names(doc, &boards)
.into_iter()
.zip(files)
.map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), Cow::Borrowed(bytes.as_slice())))
let mut names = super::artboard_file_names(doc, &boards).into_iter();
files
.iter()
.map(|(b, bytes)| {
let name = match b {
None => path.to_string(),
Some(_) => sibling(&format!("{stem}{}{}{ext}", self.joiner.unwrap_or("-"), names.next().unwrap_or_default())),
};
(name, Cow::Borrowed(bytes.as_slice()))
})
.collect()
}
};
@@ -152,6 +163,18 @@ struct RasterOptions {
transparency: Option<bool>,
matte: Option<Value>,
lossless: Option<bool>,
lzw: Option<bool>,
byte_order: Option<String>,
/// BMP and Targa bits per pixel.
depth: Option<u8>,
/// BMP layout: `windows` or `os2`.
file_format: Option<String>,
rle: Option<bool>,
flip_rows: Option<bool>,
/// PSD: Write Layers.
layers: Option<bool>,
max_editability: Option<bool>,
hidden_layers: Option<bool>,
}
impl RasterOptions {
@@ -162,6 +185,8 @@ impl RasterOptions {
return Err(bad(C, format!("ppi must be a positive number, not {ppi}")));
}
let ppi = self.ppi.unwrap_or(72.0 * self.scale.unwrap_or(1.0));
let color_model = parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?;
let embed_icc = self.embed_icc.unwrap_or(true);
Ok(RasterExportOptions {
ppi: (ppi / 72.0).clamp(0.01, 64.0) * 72.0,
background: match &self.background {
@@ -172,10 +197,10 @@ impl RasterOptions {
interlaced: self.interlaced.unwrap_or(false),
quality: self.quality.unwrap_or(90).min(100),
jpeg: JpegOptions {
color_model: parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?,
color_model,
method: parse(self.method.as_deref(), jpeg::Method::from_id, "method", "baseline, optimized or progressive")?,
scans: self.scans.unwrap_or(3).clamp(*jpeg::SCANS.start(), *jpeg::SCANS.end()),
embed_icc: self.embed_icc.unwrap_or(true),
embed_icc,
},
palette: PaletteOptions {
colors: self.colors.unwrap_or(256).clamp(2, 256),
@@ -193,10 +218,54 @@ impl RasterOptions {
None => Some([255; 3]),
},
},
tiff: TiffOptions {
color_model,
lzw: self.lzw.unwrap_or(true),
byte_order: parse(self.byte_order.as_deref(), ByteOrder::from_id, "byteOrder", "little or big")?,
embed_icc,
},
bmp: bmp::BmpOptions {
os2: parse(self.file_format.as_deref(), bmp_layout, "fileFormat", "windows or os2")?,
depth: self.depth.unwrap_or(24),
rle: self.rle.unwrap_or(false),
top_down: self.flip_rows.unwrap_or(false),
gray: color_model == jpeg::ColorModel::Gray,
},
tga: tga::TgaOptions { depth: self.depth.unwrap_or(24) },
psd: psd::PsdOptions {
color_model,
layers: self.layers.unwrap_or(true),
max_editability: self.max_editability.unwrap_or(false),
hidden_layers: self.hidden_layers.unwrap_or(false),
embed_icc,
},
})
}
}
/// The BMP layout by name: `true` for OS/2.
fn bmp_layout(s: &str) -> Option<bool> {
match s.to_ascii_lowercase().as_str() {
"windows" => Some(false),
"os2" | "os/2" => Some(true),
_ => None,
}
}
/// Refuse options `format` can't write together: BMP and Targa depths and layouts, and colour
/// models (BMP is RGB or grey, Targa RGB).
fn check_options(format: RasterFormat, o: &RasterExportOptions) -> Result<()> {
let model = o.jpeg.color_model;
let r = match format {
RasterFormat::Bmp if model == jpeg::ColorModel::Cmyk => Err("BMP files are RGB or grayscale: colorModel rgb or gray".to_string()),
RasterFormat::Tga if model != jpeg::ColorModel::Rgb => Err("Targa files are RGB: colorModel rgb".to_string()),
RasterFormat::Bmp => o.bmp.check(),
RasterFormat::Tga => o.tga.check(),
_ => Ok(()),
};
r.map_err(|e| bad(C, e))
}
/// An enum option by id (its default when absent).
fn parse<T: Default>(v: Option<&str>, from_id: fn(&str) -> Option<T>, name: &str, ids: &str) -> Result<T> {
v.map(|s| from_id(s).ok_or_else(|| bad(C, format!("{name} `{s}`: {ids}")))).transpose().map(Option::unwrap_or_default)
@@ -263,10 +332,15 @@ const WEBP_SIDE: f64 = 16383.0;
/// Refuse a raster export its format can't store (instead of writing an empty file). The
/// renderer's own size limits are [`vectorcraft_render::raster_size`]'s.
fn check_format_size(f: &Format, w: f64, h: f64) -> Result<()> {
if f.id == "webp" && (w.round() > WEBP_SIDE || h.round() > WEBP_SIDE) {
let max = match f.id {
"webp" => WEBP_SIDE,
"psd" => f64::from(psd::MAX_SIDE),
_ => return Ok(()),
};
if w.round() > max || h.round() > max {
return Err(bad(
C,
format!("{} × {} pixels is too large for {} (at most {WEBP_SIDE} pixels a side): lower the scale", w.round(), h.round(), f.label),
format!("{} × {} pixels is too large for {} (at most {max} pixels a side): lower the scale", w.round(), h.round(), f.label),
));
}
Ok(())
@@ -293,7 +367,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let doc = &*doc.without_edit_modes();
let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf") { use_artboards(p)? } else { None };
let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
if use_artboards == Some(false) {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p);
@@ -313,11 +387,15 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
"txt" => super::text::encode(doc, p)?,
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
"dxf" => return super::dxf::encode(doc, p, use_artboards),
// EPS reads its own `useArtboards` (the art's bounds unless asked).
"eps" => return super::eps::encode(doc, p),
"emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf),
"wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf),
"pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
}
"png" | "jpg" | "webp" | "gif" | "png8" => {
"png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" | "psd" => {
let o: RasterOptions = options(f, p)?;
// New Document → Background Contents: White makes the export opaque, unless `background`
// says otherwise (JPEG has no alpha: white either way).
@@ -329,8 +407,13 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
"jpg" => RasterFormat::Jpeg,
"gif" => RasterFormat::Gif,
"png8" => RasterFormat::Png8,
"tiff" => RasterFormat::Tiff,
"bmp" => RasterFormat::Bmp,
"tga" => RasterFormat::Tga,
"psd" => RasterFormat::Psd,
_ => RasterFormat::WebP,
};
check_options(format, &settings)?;
// Use Artboards: one file per chosen artboard (default all); else the one chosen.
let chosen = match use_artboards {
Some(true) => boards(o.boards.resolve(n))?.unwrap_or_else(|| (0..n).collect()),
+303
View File
@@ -0,0 +1,303 @@
//! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As
//! → EPS Options): the options parsed from the params, transparency flattened with a flattener
//! preset, one file of the art's bounds or one per artboard, the preview and thumbnail rendered,
//! and the native document embedded for reopening.
use std::borrow::Cow;
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster};
use vectorcraft_geom::{Point, Rect};
use super::super::flatten::{FlattenOptions, flatten_document};
use super::super::*;
use super::{ArtboardPick, Encoded, FormatOption};
const C: &str = "document.export";
/// Most pixels along a side of a preview.
const MAX_PREVIEW: f64 = 2048.0;
/// Most pixels along a side of a thumbnail.
const THUMBNAIL: f64 = 128.0;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"document.exportEps",
"Export EPS",
[],
None,
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
has_doc,
export_eps
)]
}
/// The EPS options `document.formats` lists.
pub(super) const OPTIONS: &[FormatOption] = &[
super::ARTBOARD,
super::ARTBOARDS,
super::RANGE,
FormatOption {
name: "useArtboards",
ty: "boolean",
default: "null",
description: "true: one file per chosen artboard (default all), <file>_<artboard>.eps; false or absent: one file of the visible art (naming artboards also uses them)",
},
FormatOption { name: "level", ty: "integer", default: "3", description: "PostScript language level, 2 or 3" },
FormatOption {
name: "previewFormat",
ty: "string",
default: "\"tiffColor\"",
description: "none, tiffBw (1 bit) or tiffColor: a TIFF preview other apps show",
},
FormatOption {
name: "transparentPreview",
ty: "boolean",
default: "true",
description: "the colour preview keeps transparency (false: on white)",
},
FormatOption { name: "overprints", ty: "string", default: "\"preserve\"", description: "preserve or discard overprinting fills and strokes" },
FormatOption {
name: "flattenerPreset",
ty: "string",
default: "\"medium\"",
description: "the flattener preset transparency is flattened with: high, medium, low or a saved one",
},
FormatOption {
name: "flattener",
ty: "object",
default: "null",
description: "flattener options over the preset (as object.flattenTransparency)",
},
FormatOption {
name: "embedFonts",
ty: "boolean",
default: "true",
description: "type is written as outlines, so the file needs no fonts either way",
},
FormatOption {
name: "includeLinkedFiles",
ty: "boolean",
default: "false",
description: "the embedded document keeps linked images whole (else their previews)",
},
FormatOption { name: "thumbnails", ty: "boolean", default: "true", description: "a small PNG thumbnail in the file" },
FormatOption {
name: "cmykPostScript",
ty: "boolean",
default: "true",
description: "RGB documents are written in CMYK, for devices without RGB",
},
FormatOption { name: "compatibleGradients", ty: "boolean", default: "false", description: "gradients as stepped fills at level 3 too" },
FormatOption { name: "selectedOnly", ty: "boolean", default: "false", description: "only the selected objects, in their layers" },
];
/// The EPS params, as they come (each checked when read).
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct Params {
#[serde(flatten)]
boards: ArtboardPick,
use_artboards: Option<bool>,
level: Option<Value>,
preview_format: Option<String>,
transparent_preview: Option<bool>,
overprints: Option<String>,
flattener_preset: Option<String>,
flattener: Option<Value>,
include_linked_files: Option<bool>,
thumbnails: Option<bool>,
cmyk_post_script: Option<bool>,
compatible_gradients: Option<bool>,
}
/// The settings of one EPS export.
struct Settings {
/// The writer's options (the region, origin and native document are set per file).
eps: EpsOptions,
flatten: FlattenOptions,
include_linked: bool,
thumbnails: bool,
boards: ArtboardPick,
use_artboards: Option<bool>,
}
/// An option read by `read`, or an error listing what it may be.
fn choice<T>(field: &str, v: Option<String>, read: impl Fn(&str) -> Option<T>, default: T, allowed: &str) -> Result<T> {
match v {
None => Ok(default),
Some(s) => read(&s).ok_or_else(|| bad(C, format!("EPS {field} `{s}`: {allowed}"))),
}
}
/// The flattener options `p` asks for (`flattenerPreset` and `flattener` over it), built-in
/// presets and `saved` ones; discarded overprints are dropped from the flattened art too.
fn flattener(p: &Params, saved: &[super::super::FlattenerPreset]) -> Result<FlattenOptions> {
FlattenOptions::for_export(p.flattener_preset.as_deref(), p.flattener.as_ref(), saved).map_err(|e| bad(C, format!("EPS flattener: {e}")))
}
fn params(p: &Value) -> Result<Params> {
if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("EPS options: {e}"))) } else { Ok(Params::default()) }
}
fn parse(p: &Value) -> Result<Settings> {
let q = params(p)?;
let d = EpsOptions::default();
let overprint = choice("overprints", q.overprints.clone(), Overprint::from_id, d.overprint, "preserve or discard")?;
let mut flatten = flattener(&q, &[])?;
flatten.preserve_overprints &= overprint == Overprint::Preserve;
let level = q.level.as_ref().map(super::dxf::text);
let eps = EpsOptions {
level: choice("level", level, Level::from_id, d.level, "2 or 3")?,
preview: choice("previewFormat", q.preview_format.clone(), Preview::from_id, d.preview, "none, tiffBw or tiffColor")?,
transparent_preview: q.transparent_preview.unwrap_or(true),
overprint,
cmyk: q.cmyk_post_script.unwrap_or(true),
compatible_gradients: q.compatible_gradients.unwrap_or(false),
..d
};
Ok(Settings {
eps,
flatten,
include_linked: q.include_linked_files.unwrap_or(false),
thumbnails: q.thumbnails.unwrap_or(true),
boards: q.boards,
use_artboards: q.use_artboards,
})
}
/// `p` checked (dialogs keep themselves open on an error) and with a saved flattener preset of
/// `saved` it names written out, as the encoder needs it.
pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::result::Result<Value, String> {
let run = || -> Result<Value> {
let q = expanded(C, p, saved)?.unwrap_or_else(|| p.clone());
parse(&q)?;
Ok(q)
};
run().map_err(|e| e.to_string())
}
/// `p` with a saved flattener preset it names written out as `flattener` options, for the
/// encoder, which knows only the built-in presets; `None` when `p` names none.
fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
match str_param(p, "flattenerPreset") {
Some(name) if !name.trim().is_empty() && FlattenOptions::preset(name).is_none() => {
let o = FlattenOptions::for_export(Some(name), p.get("flattener"), saved).map_err(|e| bad(cmd, format!("flattener: {e}")))?;
let mut q = p.clone();
q["flattener"] = serde_json::to_value(o).map_err(|e| EngineError::Other(e.to_string()))?;
if let Some(m) = q.as_object_mut() {
m.remove("flattenerPreset");
}
Ok(Some(q))
}
_ => Ok(None),
}
}
/// The files to write: `(artboard, region, origin)` each — every chosen artboard with its corner
/// as the origin, or the visible art's bounds with the first artboard's corner.
fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Point)>> {
let n = doc.artboards.len();
let named = set.boards.resolve(n).map_err(|e| bad(C, e))?;
let boards = match (set.use_artboards, named) {
(Some(false), _) => None,
(_, Some(b)) => Some(b),
(Some(true), None) => Some((0..n).collect()),
(None, None) => None,
};
let corner = |r: Rect| Point::new(r.x0, r.y1);
match boards {
Some(boards) => boards
.into_iter()
.map(|b| doc.artboards.get(b).map(|a| (Some(b), a.rect, corner(a.rect))).ok_or_else(|| bad(C, format!("no artboard {}", b + 1))))
.collect(),
None => {
let art = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
Ok(vec![(None, art, doc.artboards.first().map_or(corner(art), |a| corner(a.rect)))])
}
}
}
/// `rect` of `doc` drawn at a pixel a point (fewer when larger than `max` a side), straight RGBA,
/// transparent or on white.
fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result<Raster> {
let long = rect.width().max(rect.height());
let scale = if long > max { max / long } else { 1.0 };
vectorcraft_render::raster_size(rect, scale).map_err(|e| bad(C, e))?;
let img = r.render_region(doc, rect, scale, !transparent);
Ok(Raster { width: img.width, height: img.height, rgba: img.to_straight() })
}
/// What the file carries to reopen: `doc` (with only artboard `board` when the file is one of
/// several artboards), with linked images whole when `include_linked`.
fn native(doc: &Document, board: Option<usize>, include_linked: bool) -> Vec<u8> {
let several = board.filter(|_| doc.artboards.len() > 1);
if several.is_none() && !include_linked {
return vectorcraft_format::save(doc, false);
}
let mut d = doc.clone();
if let Some(a) = several.and_then(|b| doc.artboards.get(b)) {
d.artboards = vec![a.clone()];
}
if include_linked {
// A blob keeps its preview only while its file hasn't been read.
for blob in d.images.values_mut().filter(|b| !b.is_proxy()) {
blob.proxy = None;
}
}
vectorcraft_format::save(&d, false)
}
/// Encode `doc` as EPS: one file of the visible art, or with Use Artboards one per chosen
/// artboard. Transparency is flattened first (once for all of them); the previews show `doc`
/// itself.
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
let set = parse(p)?;
let pages = pages(doc, &set)?;
if pages.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let transparent = doc.layers.iter().any(|l| l.shows_transparency());
let flat = flatten_document(doc, &set.flatten)?;
let drawn = flat.as_ref().unwrap_or(doc);
let mut enc = Encoded { joiner: Some("_"), ..Encoded::default() };
if transparent && flat.is_some() {
enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into());
}
let created = vectorcraft_doc::metadata::now_unix();
let mut renderer = vectorcraft_render::Renderer::new();
for (board, region, origin) in pages {
let rect = vectorcraft_eps::preview_rect(region, origin);
let preview = match set.eps.preview {
Preview::None => None,
kind => Some(render(&mut renderer, doc, rect, MAX_PREVIEW, set.eps.transparent_preview && kind == Preview::TiffColor)?),
};
let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?;
let o =
EpsOptions { region, origin, title: doc.title.clone(), created, native: Some(native(doc, board, set.include_linked)), ..set.eps.clone() };
let out = vectorcraft_eps::export(drawn, &o, preview.as_ref(), thumbnail.as_ref()).map_err(|e| bad(C, e))?;
enc.files.push((board, out.bytes));
for w in out.warnings {
if !enc.warnings.contains(&w) {
enc.warnings.push(w);
}
}
}
Ok(enc)
}
fn export_eps(s: &mut Session, p: &Value) -> Result<Value> {
let mut q = if p.is_object() { p.clone() } else { json!({}) };
q["format"] = json!("eps");
super::export::export(s, &q)
}
/// `p` with a saved flattener preset of the session's written out (see [`expanded`]).
pub(super) fn with_flattener<'a>(s: &Session, cmd: &str, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
Ok(match expanded(cmd, &p, &s.prefs.flattener_presets)? {
Some(q) => Cow::Owned(q),
None => p,
})
}
+21 -8
View File
@@ -15,7 +15,7 @@ use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, merge, wr
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
let expanded = super::pdf::expand_preset(s, "document.serialize", p)?;
let expanded = expand_presets(s, "document.serialize", p)?;
let p = &*expanded;
let doc = &*source(s, f, p, "document.serialize")?;
let enc = encode_all(doc, f.id, p)?;
@@ -46,7 +46,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path");
let f = writable("document.export", str_param(p, "format"), path)?;
let expanded = super::pdf::expand_preset(s, "document.export", p)?;
let expanded = expand_presets(s, "document.export", p)?;
let p = &*expanded;
let doc = &*source(s, f, p, "document.export")?;
let enc = encode_all(doc, f.id, p)?;
@@ -101,6 +101,12 @@ pub(super) fn without_artboards(p: &Value) -> Value {
q
}
/// `p` with the saved presets it names (PDF, flattener) written out as options, for the encoders,
/// which know only the built-in ones.
fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
super::pdf::expand_preset(s, cmd, p)
}
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
/// with `selectionOnly` the selected objects alone.
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Cow<'a, Document>> {
@@ -118,15 +124,22 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
// On a template layer or sublayer, at any depth.
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
let nodes: Vec<Arc<Node>> = ids.into_iter().filter(|id| !on_template(*id)).filter_map(|id| st.doc.node(id).cloned().map(Arc::new)).collect();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)).ok_or_else(|| bad(cmd, "select something to export"))?;
let mut d = single_artboard(&st.doc, bounds, "Selection");
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
let ids: Vec<NodeId> = ids.into_iter().filter(|id| !on_template(*id)).collect();
isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
}
/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard
/// named `name`: their visual bounds (`None` when they have none).
pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))?;
let mut d = single_artboard(doc, bounds, name);
let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = nodes;
}
d.layers = vec![Arc::new(layer)];
Ok((d, bounds))
Some((d, bounds))
}
/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on
@@ -135,7 +148,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportSelection";
let path = str_param(p, "path");
let f = writable(C, str_param(p, "format"), path)?;
let expanded = super::pdf::expand_preset(s, C, p)?;
let expanded = expand_presets(s, C, p)?;
let p = &*expanded;
let (d, bounds) = selection(s, C)?;
let bytes = encode(&d, f.id, &without_artboards(p))?;
+2 -2
View File
@@ -125,8 +125,8 @@ fn polygon(path: &kurbo::BezPath, tolerance: f64) -> Option<Vec<kurbo::Point>> {
(pts.len() >= 3).then_some(pts)
}
/// Escape text for an HTML attribute.
fn attr(s: &str) -> String {
/// Escape text for an HTML attribute (or element text).
pub(crate) fn attr(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).fold(String::with_capacity(s.len()), |mut o, c| {
match c {
'&' => o.push_str("&amp;"),
+46 -11
View File
@@ -55,10 +55,21 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if vectorcraft_svg::is_svgz(bytes) {
return format("svgz");
}
if vectorcraft_pdf::is_postscript(bytes) {
// PostScript (.eps, and .ai saved without PDF compatibility) by any name; .ai and .ait
// keep their meaning (a template), read as PostScript all the same.
return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("eps"));
}
if bytes.starts_with(b"%PDF") {
// .ai and .ait files are PDF inside; the extension keeps the template meaning.
return by_name.filter(|f| matches!(f.id, "ai" | "ait")).or_else(|| format("pdf"));
}
if vectorcraft_cad::is_dxf(bytes) {
return format("dxf");
}
if let Some(kind) = vectorcraft_metafile::sniff(bytes) {
return format(kind.id());
}
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
return Some(f);
}
@@ -90,8 +101,8 @@ fn restore_or_import(
let mut fallback = None;
if let Some((intact, bytes)) = editing {
let native = if intact { bytes() } else { None };
match native.and_then(|b| vectorcraft_format::load(&b).ok()) {
Some(doc) => return Ok((doc, vec![], true)),
match native.and_then(|b| native_file(&b).ok()) {
Some((doc, _, warnings)) => return Ok((doc, warnings, true)),
None => fallback = Some(if intact { EDITING_DAMAGED } else { EDITING_STALE }),
}
}
@@ -102,19 +113,22 @@ fn restore_or_import(
Ok((doc, warnings, false))
}
/// A native file's document and details, the colour profiles it carries installed where missing
/// (with a warning for each that can't be used).
pub(crate) fn native_file(bytes: &[u8]) -> Result<(Document, vectorcraft_format::FileInfo, Vec<String>)> {
let f = vectorcraft_format::load_file(bytes).map_err(err)?;
let warnings = super::native::install_profiles(f.profiles);
Ok((f.doc, f.info, warnings))
}
/// Read a file of any readable format (`name`: its file name or path, for the extension and title;
/// a path's folder is where an SVG's relative image links are found).
pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
load_with(name, bytes, &LoadOptions::default())
}
/// [`load`] with `document.open` options (the PDF pages, box and password).
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
// PostScript (.ai saved in an older format or without PDF compatibility, .eps) says so, by
// any name.
if vectorcraft_pdf::is_postscript(bytes) {
return Err(err(format!("can't open `{}`: {}", file_name(name), vectorcraft_pdf::PdfError::PostScript)));
}
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
Some(u) => err(format!("can't open `{}`: {}", file_name(name), u.hint)),
None => err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")),
@@ -122,10 +136,18 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let title = file_name(name);
let mut converted = false;
let (mut doc, warnings, restored) = match format.id {
// EPS and PostScript .ai: the document our EPS files carry, else the PostScript read.
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
restore_or_import(editing, || {
let r = vectorcraft_eps::import(bytes).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?;
Ok((r.document, r.warnings))
})?
}
"vectorcraft" | "template" => {
let (d, info) = vectorcraft_format::load_info(bytes).map_err(err)?;
let (d, info, warnings) = native_file(bytes)?;
converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION;
(d, vec![], false)
(d, warnings, false)
}
"svg" | "svgz" => {
let text = vectorcraft_svg::text_of(bytes).map_err(err)?;
@@ -136,9 +158,22 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
"pdf" | "ai" | "ait" => {
// Saved with Preserve Editing: the document it carries, unless some pages are picked.
let editing = opts.pages.is_none().then(|| vectorcraft_pdf::editing(bytes)).flatten().map(|e| (e.intact, move || Some(e.data)));
let editing = opts
.pages
.is_none()
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || super::pdfimport::import(bytes, opts))?
}
"dxf" => {
let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?;
(doc, warnings, false)
}
"emf" | "wmf" => {
let (doc, warnings) = super::metafile::import(bytes)?;
(doc, warnings, false)
}
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
};
+52
View File
@@ -0,0 +1,52 @@
//! EMF and WMF for every path that writes or reads Windows metafiles (`vectorcraft_metafile`):
//! `document.export {format: emf|wmf}` and Export As (one picture of an artboard, or one per
//! artboard with Use Artboards; raster effects as images, rendered at the document's raster
//! effects resolution), `document.open` and File → Place (one artboard, the picture's frame).
use serde_json::Value;
use vectorcraft_doc::Document;
use vectorcraft_metafile::Kind;
use super::super::*;
use super::{ArtboardPick, Encoded, FormatOption};
const C: &str = "document.export";
/// The options `document.formats` lists for EMF and WMF.
pub(super) const OPTIONS: &[FormatOption] = &[super::ARTBOARD, super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS];
/// Encode `doc` as `kind`: the chosen artboard (default the first), or with `use_artboards` one
/// picture per chosen artboard (default all).
pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>, kind: Kind) -> Result<Encoded> {
let f = super::format(kind.id()).ok_or_else(|| bad(C, "no metafile format"))?;
let pick: ArtboardPick = super::encode::options(f, p)?;
let n = doc.artboards.len();
let boards = match use_artboards {
Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()),
_ => vec![pick.one(n).map_err(|e| bad(C, e))?],
};
// Raster effects (shadows, glows, blurs) as images, as PDF export does.
let flat = crate::flatten_raster_effects(doc);
let src = flat.as_ref().unwrap_or(doc);
let mut enc = Encoded::default();
for b in boards {
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
let out = vectorcraft_metafile::export(src, region, kind).map_err(|e| bad(C, e))?;
enc.files.push((Some(b), out.bytes));
for w in out.warnings {
if !enc.warnings.contains(&w) {
enc.warnings.push(w);
}
}
}
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
Ok(enc)
}
/// A metafile's document and the import's warnings.
pub(super) fn import(bytes: &[u8]) -> Result<(Document, Vec<String>)> {
let i = vectorcraft_metafile::import(bytes).map_err(super::err)?;
Ok((i.document, i.warnings))
}
+241 -24
View File
@@ -1,7 +1,7 @@
//! Document file I/O for every frontend (desktop, web, CLI, control channel, headless MCP): open,
//! save, export and serialize by path or bytes (base64), the format table, and `command.batch`.
//!
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, raster images).
//! - `load`: `document.open` (native, legacy, SVG/SVGZ, PDF/.ai/.ait, DXF, raster images).
//! - `encode`: one encoder per writable format, with typed options parsed from the params.
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
@@ -12,16 +12,22 @@
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`.
//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT).
//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
//! - `metafile`: EMF and WMF export, open and place.
//!
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
//! agents query `document.formats`.
mod batch;
pub mod dxf;
pub mod dxfimport;
mod encode;
pub mod eps;
mod export;
mod imagemap;
pub(crate) mod imagemap;
mod load;
mod metafile;
mod native;
pub mod pdf;
mod pdfimport;
@@ -39,17 +45,23 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub use export::export_source;
pub(crate) use export::isolated;
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::native_file;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, export_folder, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
templates_folder,
};
pub use screens::{PRESETS as SCREEN_PRESETS, ScreenSize, preset_rows as screen_preset_rows};
pub(crate) use screens::{
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings,
};
pub use svg::options_map as svg_options;
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
/// file it replaces), for the apps' own writers too.
@@ -66,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
@@ -75,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
has_doc,
export::serialize
),
@@ -84,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, bmp: colorModel?: rgb|gray, depth?: 24 (1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), tga: depth?: 24 (16 one-bit alpha, 24, 32 with alpha), psd: colorModel?: rgb|cmyk|gray, layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true, at most 30000 pixels a side; formats without alpha are flattened on white} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -102,11 +114,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png). The params are remembered in the document (document.exportSettings; not an undo step)",
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|svg|svgz|pdf, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|svg|pdf: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
has_doc,
screens::export_for_screens
),
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch::batch),
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", has_doc, batch::batch),
cmd!(
query "document.formats",
"File Formats",
@@ -328,6 +340,96 @@ const PALETTE_OPTIONS: &[FormatOption] = &[
MATTE,
];
const LZW: FormatOption = FormatOption { name: "lzw", ty: "boolean", default: "true", description: "LZW compression (lossless, smaller files)" };
const BYTE_ORDER: FormatOption = FormatOption {
name: "byteOrder",
ty: "string",
default: "\"little\"",
description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)",
};
const TIFF_OPTIONS: &[FormatOption] =
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
const BMP_COLOR_MODEL: FormatOption =
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
const BMP_DEPTH: FormatOption = FormatOption {
name: "depth",
ty: "integer",
default: "24",
description: "bits per pixel: 1 (black and white), 4 or 8 (a palette by reduction and dither), 16, 24, or 32 (keeps transparency); the others are flattened on white",
};
const FILE_FORMAT: FormatOption = FormatOption {
name: "fileFormat",
ty: "string",
default: "\"windows\"",
description: "windows, or os2 (1, 4, 8 or 24 bits, uncompressed, rows bottom-up)",
};
const RLE: FormatOption =
FormatOption { name: "rle", ty: "boolean", default: "false", description: "run-length encode a 4- or 8-bit windows bitmap (RLE4, RLE8)" };
const FLIP_ROWS: FormatOption = FormatOption {
name: "flipRows",
ty: "boolean",
default: "false",
description: "rows top-down (a negative height) instead of bottom-up; not with rle or os2",
};
const BMP_OPTIONS: &[FormatOption] = &[
ARTBOARD,
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
PPI,
SCALE,
BACKGROUND,
ANTI_ALIAS,
BMP_COLOR_MODEL,
BMP_DEPTH,
FILE_FORMAT,
RLE,
FLIP_ROWS,
REDUCTION,
DITHER,
DITHER_AMOUNT,
];
const TGA_DEPTH: FormatOption = FormatOption {
name: "depth",
ty: "integer",
default: "24",
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
};
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const PSD_LAYERS: FormatOption = FormatOption {
name: "layers",
ty: "boolean",
default: "true",
description: "write layers: each top-level layer a pixel layer with its opacity and blend mode (a background colour is a Background layer); false: one flat image, on white where transparent",
};
const MAX_EDITABILITY: FormatOption = FormatOption {
name: "maxEditability",
ty: "boolean",
default: "false",
description: "with layers: layers and sublayers become groups and every object a layer of its own, named as the Layers panel names it (text by its text); clipping, masked or knockout layers stay one layer",
};
const PSD_HIDDEN_LAYERS: FormatOption = FormatOption {
name: "hiddenLayers",
ty: "boolean",
default: "false",
description: "with layers: hidden layers (and hidden objects) are written as hidden layers instead of left out",
};
const PSD_OPTIONS: &[FormatOption] = &[
ARTBOARD,
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
PPI,
SCALE,
BACKGROUND,
ANTI_ALIAS,
COLOR_MODEL,
PSD_LAYERS,
MAX_EDITABILITY,
PSD_HIDDEN_LAYERS,
EMBED_ICC,
];
/// A format `document.open` reads but nothing writes yet.
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
Format { id, label, extensions, mime, read: true, write: false, raster, options: &[] }
@@ -357,7 +459,17 @@ pub const FORMATS: &[Format] = &[
options: svg::OPTIONS,
},
Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS },
reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false),
Format {
id: "ai",
label: "PDF-compatible .ai",
extensions: &["ai"],
mime: "application/pdf",
read: true,
// Saved (Save As), not exported.
write: false,
raster: false,
options: native::AI_OPTIONS,
},
reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false),
Format {
id: "png",
@@ -405,8 +517,17 @@ pub const FORMATS: &[Format] = &[
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
},
reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true),
reader("bmp", "BMP", &["bmp"], "image/bmp", true),
Format {
id: "tiff",
label: "TIFF",
extensions: &["tif", "tiff"],
mime: "image/tiff",
read: true,
write: true,
raster: true,
options: TIFF_OPTIONS,
},
Format { id: "bmp", label: "BMP", extensions: &["bmp"], mime: "image/bmp", read: true, write: true, raster: true, options: BMP_OPTIONS },
Format {
id: "template",
label: "VectorCraft Template",
@@ -419,19 +540,74 @@ pub const FORMATS: &[Format] = &[
},
Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS },
Format { id: "txt", label: "Text", extensions: TEXT_EXTS, mime: "text/plain", read: false, write: true, raster: false, options: text::OPTIONS },
Format { id: "dxf", label: "DXF", extensions: &["dxf"], mime: "image/vnd.dxf", read: false, write: true, raster: false, options: dxf::OPTIONS },
Format { id: "dxf", label: "DXF", extensions: &["dxf"], mime: "image/vnd.dxf", read: true, write: true, raster: false, options: dxf::OPTIONS },
Format {
id: "eps",
label: "EPS",
extensions: &["eps"],
mime: "application/postscript",
read: true,
write: true,
raster: false,
options: eps::OPTIONS,
},
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: false, write: true, raster: true, options: PSD_OPTIONS },
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
pub const OPEN_EXTS: &[&str] =
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate"];
pub const OPEN_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
"svg",
"svgz",
"pdf",
"ai",
"ait",
"png",
"jpg",
"jpeg",
"gif",
"webp",
"tif",
"tiff",
"bmp",
"vctemplate",
"dxf",
"emf",
"wmf",
"eps",
];
/// Text files: File → Place sets them as area type (Text Import Options).
pub const TEXT_EXTS: &[&str] = &["txt"];
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
pub const PLACE_EXTS: &[&str] =
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "txt"];
pub const PLACE_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
"svg",
"svgz",
"pdf",
"ai",
"ait",
"png",
"jpg",
"jpeg",
"gif",
"webp",
"tif",
"tiff",
"bmp",
"vctemplate",
"dxf",
"emf",
"wmf",
"eps",
"txt",
];
/// One dialog filter per readable format.
fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
@@ -439,13 +615,15 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
}
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel), flattener presets and PDF presets (imported).
/// libraries (which open in the library panel), flattener, PDF and print presets (imported).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS))
.chain(format_filters())
.chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS)))
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.
@@ -585,14 +763,20 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
/// case: `Icon` and `icon` are one file on most desktop file systems).
pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))))
}
/// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
/// (compared without case).
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
let mut taken = std::collections::HashSet::new();
boards
.iter()
.map(|&b| {
let name = doc.artboards.get(b).map_or("", |a| a.name.as_str());
names
.into_iter()
.map(|(name, fallback)| {
let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() {
base = format!("Artboard-{}", b + 1);
base = fallback;
}
let mut name = base.clone();
for i in 2.. {
@@ -636,7 +820,7 @@ fn write_encoded(path: Option<&str>, name: &str, doc: &vectorcraft_doc::Document
}
/// `a` with the fields of `b`.
fn merge(mut a: Value, b: Value) -> Value {
pub(crate) fn merge(mut a: Value, b: Value) -> Value {
if let (Some(a), Value::Object(b)) = (a.as_object_mut(), b) {
a.extend(b);
}
@@ -644,13 +828,13 @@ fn merge(mut a: Value, b: Value) -> Value {
}
/// A file name for bytes handed back without a path: the document's title with `ext`.
fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String {
pub(crate) fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String {
let stem = std::path::Path::new(&doc.title).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty());
format!("{}.{ext}", stem.as_deref().unwrap_or("Untitled"))
}
/// Write `bytes` to `path` → `{path, bytes, …extra}`; with no path → `{dataBase64, bytes, …extra}`.
fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result<Value> {
pub(crate) fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result<Value> {
let out = match path {
Some(path) => {
write_file(path, bytes)?;
@@ -697,7 +881,40 @@ mod tests_text;
#[cfg(test)]
mod tests_dxf;
#[cfg(test)]
mod tests_pdfsecurity;
#[cfg(test)]
mod tests_svgenc;
#[cfg(test)]
mod tests_svgimport;
#[cfg(test)]
mod tests_eps;
#[cfg(test)]
mod tests_dxfimport;
#[cfg(test)]
mod tests_metafile;
#[cfg(test)]
mod tests_pdfoutput;
#[cfg(test)]
mod tests_tiffbmp;
#[cfg(test)]
mod tests_pdfx;
#[cfg(test)]
mod tests_psd;
#[cfg(test)]
mod tests_epsimport;
#[cfg(test)]
mod tests_pdflayers;
#[cfg(test)]
mod tests_pdfwebview;
#[cfg(test)]
mod tests_pdf13;
+127 -9
View File
@@ -1,8 +1,13 @@
//! The native format's options: compression, the version to write (older apps read older
//! versions) and an embedded preview.
//! versions), an embedded preview, and the save options native and `.ai` files share: each
//! artboard to a separate file, Include Linked Files, Embed ICC Profiles and Create
//! PDF-Compatible File.
use std::collections::BTreeMap;
use serde::Deserialize;
use serde_json::Value;
use serde_json::{Value, json};
use vectorcraft_color::cms;
use vectorcraft_doc::Document;
use vectorcraft_format::{PREVIEW_MAX, SaveOptions};
@@ -29,33 +34,146 @@ pub const OPTIONS: &[FormatOption] = &[
default: "false",
description: "embed a PNG preview of the first artboard (at most 256 pixels on its longer side) for file browsers",
},
SEPARATE_ARTBOARDS,
SEPARATE_RANGE,
INCLUDE_LINKED,
EMBED_PROFILES,
FormatOption {
name: "pdfCompatible",
ty: "boolean",
default: "false",
description: "also carry a PDF of every artboard, for apps that read PDF (a larger file)",
},
];
const SEPARATE_ARTBOARDS: FormatOption = FormatOption {
name: "separateArtboards",
ty: "boolean",
default: "false",
description: "also save each artboard (of range) to a file of its own beside this one, <name>-<artboard>.<ext>, holding that artboard and the art on it",
};
const SEPARATE_RANGE: FormatOption = FormatOption {
name: "range",
ty: "string",
default: "null",
description: "with separateArtboards: the artboards saved separately, 1-based such as \"1-3, 5\", or \"all\" (default)",
};
const INCLUDE_LINKED: FormatOption = FormatOption {
name: "includeLinked",
ty: "boolean",
default: "false",
description: "keep the linked files' own pixels in the file (they stay linked), not just a preview: the document shows them in full where the files can't be found",
};
const EMBED_PROFILES: FormatOption = FormatOption {
name: "embedProfiles",
ty: "boolean",
default: "true",
description: "carry the ICC profiles the document is tagged with that were loaded from files (Edit → Assign Profile), so the document keeps them where they aren't installed",
};
/// The save options of a `.ai` file (a PDF carrying the native document; its PDF options come on
/// top, see document.exportPdf).
pub const AI_OPTIONS: &[FormatOption] = &[
SEPARATE_ARTBOARDS,
SEPARATE_RANGE,
INCLUDE_LINKED,
EMBED_PROFILES,
FormatOption {
name: "pdfCompatible",
ty: "boolean",
default: "true",
description: "write the artwork as PDF pages that other apps show; false keeps only the native document (smaller, faster; other apps show empty pages)",
},
FormatOption {
name: "compress",
ty: "boolean",
default: "true",
description: "compress the PDF's content and the native document it carries (the PDF option compression.compressText)",
},
];
#[derive(Default, Deserialize)]
#[serde(default)]
#[serde(default, rename_all = "camelCase")]
struct NativeOptions {
compress: Option<bool>,
version: Option<u32>,
preview: bool,
include_linked: bool,
embed_profiles: Option<bool>,
pdf_compatible: bool,
}
/// The options in `p` for saving `doc`, checked (the preview is left to [`encode`]) → them and
/// whether to embed a preview.
pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, bool)> {
/// What [`save_options`] leaves to [`encode`] (rendering and encoding take time).
pub(super) struct Extras {
preview: bool,
pdf: bool,
}
/// The options in `p` for saving `doc`, checked, with the profiles to embed (the preview and PDF
/// are left to [`encode`]).
pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, Extras)> {
let o: NativeOptions = super::encode::options(f, p)?;
let mut so = SaveOptions::for_doc(doc);
so.compress = o.compress.unwrap_or(false);
so.version = o.version.unwrap_or(vectorcraft_format::VERSION);
so.include_linked = o.include_linked;
if o.embed_profiles.unwrap_or(true) {
so.profiles = embedded_profiles(doc);
}
so.check().map_err(|e| bad(cmd, e.to_string()))?;
Ok((so, o.preview))
Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible }))
}
/// The `.vectorcraft` file of `doc` with the options in `p`.
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
let (mut so, preview) = save_options(cmd, f, doc, p)?;
if preview {
let (mut so, extras) = save_options(cmd, f, doc, p)?;
if extras.preview {
so.preview = preview_png(doc)?;
}
if extras.pdf {
// Every artboard as PDF pages (no editing data: the file is the native document).
so.pdf = Some(super::pdf::encode(cmd, doc, &json!({ "range": "all", "preserveEditing": false }))?.0);
}
vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))
}
/// The ICC files of the profiles `doc` is tagged with that were loaded from files (the built-in
/// ones are there wherever VectorCraft runs).
fn embedded_profiles(doc: &Document) -> BTreeMap<String, Vec<u8>> {
[&doc.color_profiles.rgb, &doc.color_profiles.cmyk]
.into_iter()
.flatten()
.filter_map(|name| cms::profile(name).filter(|p| !p.builtin))
.filter_map(|p| Some((p.name.clone(), cms::icc_bytes(&p.name).ok()?.to_vec())))
.collect()
}
/// Most profiles read from one file.
const MAX_PROFILES: usize = 8;
/// Make the profiles a file carries available (those not installed here), named as in the file →
/// a warning per profile that can't be used.
pub(super) fn install_profiles(profiles: BTreeMap<String, Vec<u8>>) -> Vec<String> {
// A document is tagged with an RGB and a CMYK profile at most: more is junk.
profiles
.into_iter()
.take(MAX_PROFILES)
.filter(|(name, _)| cms::profile(name).is_none())
.filter_map(|(name, icc)| {
cms::register_icc(&icc, Some(name.clone())).err().map(|e| format!("the colour profile “{name}” the file carries can't be used: {e}"))
})
.collect()
}
/// The native document a `.ai` file carries, with the options in `p` that apply to it
/// (`includeLinked`, `embedProfiles`; compact and uncompressed: the PDF compresses it).
pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.retain(|k, _| !matches!(k.as_str(), "compress" | "version" | "preview" | "pdfCompatible"));
}
let (mut so, _) = save_options(cmd, f, doc, &q)?;
so.pretty = false;
vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))
}
+171 -27
View File
@@ -3,12 +3,14 @@
//! over them, the warnings that come back, and `document.pdfSettings` (the dialog's Summary).
use std::borrow::Cow;
use std::sync::Arc;
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard, THUMBNAIL_SIZE, Thumbnail};
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
use super::super::*;
use super::{ArtboardPick, FormatOption, write_or_return};
use crate::EngineError;
@@ -30,8 +32,18 @@ pub(super) const OPTIONS: &[FormatOption] = &[
default: "\"VectorCraft Default\"",
description: "the PDF preset the other options apply over (built-in or saved: pdf.preset.list)",
},
FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" },
FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" },
FormatOption {
name: "standard",
ty: "string",
default: "\"none\"",
description: "none | pdfA2b | pdfX1a | pdfX3 | pdfX4 (choosing one sets the compatibility it allows; see document.exportPdf)",
},
FormatOption {
name: "compatibility",
ty: "string",
default: "\"1.7\"",
description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0",
},
FormatOption {
name: "preserveEditing",
ty: "boolean",
@@ -46,15 +58,48 @@ pub(super) const OPTIONS: &[FormatOption] = &[
default: "null",
description: "{useDocument, top, bottom, left, right} in points: the page grows by the bleed (BleedBox) around its artboard (TrimBox)",
},
FormatOption { name: "output", ty: "object", default: "null", description: "colour conversion and output intent (see document.exportPdf)" },
FormatOption { name: "advanced", ty: "object", default: "null", description: "{fontSubsetPercent, outlineText, overprint}" },
FormatOption { name: "security", ty: "object", default: "null", description: "passwords (not supported yet) and permissions" },
FormatOption {
name: "output",
ty: "object",
default: "null",
description: "colour conversion to a destination profile, ICC profiles, output intent and Trapped (see document.exportPdf)",
},
FormatOption {
name: "advanced",
ty: "object",
default: "null",
description: "{fontSubsetPercent, outlineText (false: real text in embedded subset fonts), overprint}",
},
FormatOption {
name: "security",
ty: "object",
default: "null",
description: "passwords and permissions (the file is encrypted: RC4 40-bit at 1.3, RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)",
},
FormatOption {
name: "includeNonPrinting",
ty: "boolean",
default: "false",
description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)",
},
FormatOption {
name: "createLayers",
ty: "boolean",
default: "false",
description: "top-level layers as PDF layers (optional content groups with their visibility, print state and lock; PDF 1.5 or later)",
},
FormatOption {
name: "flattenerPreset",
ty: "string",
default: "\"\"",
description: "the flattener preset PDF 1.3 files (PDF/X-1a and PDF/X-3 too) flatten transparency with: empty (High Resolution), high, medium, low or a saved one",
},
FormatOption {
name: "flattener",
ty: "object",
default: "null",
description: "flattener options over flattenerPreset (as object.flattenTransparency)",
},
];
pub fn specs() -> Vec<CommandSpec> {
@@ -64,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export PDF",
[],
None,
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options",
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each top-level layer but template layers as a PDF layer, an optional content group: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet c Line truncated
has_doc,
export_pdf
),
@@ -109,7 +154,7 @@ pub fn preset_settings(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfS
/// Merge `over` into `base`: objects key by key (recursively), `null` keeps the base (the
/// preset's value), anything else replaces.
fn merge(base: &mut Value, over: &Value) {
pub(crate) fn merge(base: &mut Value, over: &Value) {
match (base, over) {
(Value::Object(b), Value::Object(o)) => {
for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) {
@@ -121,7 +166,7 @@ fn merge(base: &mut Value, over: &Value) {
}
/// A writer error as an engine error: bad settings are bad params of `cmd`.
fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
match e {
PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()),
e => EngineError::Other(e.to_string()),
@@ -130,9 +175,10 @@ fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
/// The settings `p` asks for: its preset (built-in or one of `saved`; default
/// [`DEFAULT_PRESET`]) with `p`'s options applied over it. Choosing another standard turns
/// Preserve Editing off unless `p` asks for it. Checked as a preset is
/// ([`PdfSettings::check_values`]): a standard the writer doesn't produce yet passes. Keys that
/// aren't PDF options (path, format…) are ignored.
/// Preserve Editing off, the layers off where it has none, and sets the latest compatibility it
/// allows, unless `p` asks for those. Checked as a preset is
/// ([`PdfSettings::check_values`]): profiles that aren't there pass. Keys that aren't PDF options
/// (path, format…) are ignored.
pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
let base = preset_settings(cmd, p, saved)?;
if !p.is_object() {
@@ -141,8 +187,17 @@ pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings>
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
merge(&mut v, p);
let mut s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?;
if s.standard != Standard::None && s.standard != base.standard && p.get("preserveEditing").is_none_or(Value::is_null) {
s.preserve_editing = false;
let unasked = |key: &str| p.get(key).is_none_or(Value::is_null);
if s.standard != base.standard {
if s.standard != Standard::None && unasked("preserveEditing") {
s.preserve_editing = false;
}
if !s.standard.allows_layers() && unasked("createLayers") {
s.create_layers = false;
}
if !s.standard.allows(s.compatibility) && unasked("compatibility") {
s.compatibility = s.standard.version();
}
}
s.check_values().map_err(|e| pdf_error(cmd, e))?;
Ok(s)
@@ -160,10 +215,11 @@ pub fn settings(cmd: &str, p: &Value) -> Result<PdfSettings> {
settings_with(cmd, p, &[])
}
/// `p` with a saved preset it names written out as options, for the encoders, which know only the
/// built-in presets. Params naming no preset, or a built-in one, come back as they are.
/// `p` with the saved presets it names (a PDF preset, a flattener preset: see
/// [`super::eps::with_flattener`]) written out as options, for the encoders, which know only the
/// built-in presets. Params naming none come back as they are.
pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
match str_param(p, "preset") {
let expanded = match str_param(p, "preset") {
Some(name) if vectorcraft_pdf::builtin_preset(name).is_none() => {
let set = settings_with(cmd, p, &s.prefs.pdf_presets)?;
let mut q = p.clone();
@@ -171,10 +227,11 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
if let Some(o) = q.as_object_mut() {
o.remove("preset");
}
Ok(Cow::Owned(q))
Cow::Owned(q)
}
_ => Ok(Cow::Borrowed(p)),
}
_ => Cow::Borrowed(p),
};
super::eps::with_flattener(s, cmd, expanded)
}
/// The full export options for `doc`: settings plus the artboards `p` picks (default all).
@@ -192,24 +249,111 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str =
/// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself
/// when the PDF has every artboard (a PDF of some reopens as just those).
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
encode_carrying(cmd, doc, p, || Ok(vectorcraft_format::save(doc, false)))
}
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
/// native document with its save options, whose pages may be left blank).
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
let mut opts = options(cmd, doc, p)?;
if opts.settings.thumbnails {
opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
}
let mut warnings = vec![];
if opts.settings.preserve_editing {
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
opts.native = Some(vectorcraft_format::save(doc, false));
opts.native = Some(native()?);
} else {
opts.settings.preserve_editing = false;
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
}
}
let r = super::super::rasterfx::export_pdf_with_report(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
warnings.extend(r.warnings);
if opts.settings.advanced.overprint == Overprint::Preserve && doc.layers.iter().any(|l| l.has_overprint()) {
warnings.push("overprinting objects are written without overprint: overprint is not written to PDF yet".into());
let set = &opts.settings;
let flat = flatten_for(cmd, doc, set, p)?;
if flat.is_some() {
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
}
let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?;
warnings.extend(r.warnings);
Ok((r.bytes, warnings))
}
/// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty.
pub const DEFAULT_FLATTENER: &str = "High Resolution";
/// `doc` with its transparency flattened for a PDF 1.3 file (asked for, or PDF/X-1a and PDF/X-3,
/// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener`
/// options over it; images with see-through pixels count as transparency, rasterized areas are
/// clipped to their regions, and discarded overprints are dropped. `None` when nothing changes (or
/// the file keeps transparency).
fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result<Option<Document>> {
if !set.pdf13() {
return Ok(None);
}
let name = Some(set.flattener_preset.trim()).filter(|n| !n.is_empty()).unwrap_or(DEFAULT_FLATTENER);
let mut o = FlattenOptions::for_export(Some(name), p.get("flattener"), &[]).map_err(|e| bad(cmd, format!("PDF flattener: {e}")))?;
o.preserve_overprints &= set.advanced.overprint == Overprint::Preserve;
o.no_transparency = Some(see_through_images(doc));
// Without soft masks a rasterized area can't be a rectangle with see-through pixels around
// its art: it is clipped to its regions (opaque inside them).
o.clip_complex_regions = true;
flatten_document(doc, &o)
}
/// The pages `opts` export of `doc` drawn on white, [`THUMBNAIL_SIZE`] pixels on their long side:
/// the thumbnails Embed Page Thumbnails embeds. Layers the pages leave out (non-printing ones,
/// unless asked for) are left out of them too.
fn thumbnails(doc: &Document, opts: &PdfOptions) -> std::result::Result<Vec<Thumbnail>, PdfError> {
let areas = vectorcraft_pdf::page_areas(doc, opts)?;
let set = &opts.settings;
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
let shown = printed.map(|layers| {
let mut d = doc.clone();
d.layers = layers;
d
});
let doc = shown.as_ref().unwrap_or(doc);
let mut r = vectorcraft_render::Renderer::new();
Ok(areas
.into_iter()
.map(|a| {
let img = r.render_region(doc, a, f64::from(THUMBNAIL_SIZE) / a.width().max(a.height()).max(1.0), true);
// Drawn on white: every pixel is opaque.
let rgb = img.pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
Thumbnail { width: img.width, height: img.height, rgb }
})
.collect())
}
/// `layers` with the non-printing layers among them (at any depth) hidden; `None` when there are
/// none.
fn without_non_printing(layers: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
let mut out: Option<Vec<Arc<Node>>> = None;
for (i, l) in layers.iter().enumerate() {
let NodeKind::Layer { printable, children, .. } = &l.kind else { continue };
let new = if !printable {
l.visible.then(|| {
let mut n = (**l).clone();
n.visible = false;
Arc::new(n)
})
} else {
without_non_printing(children).map(|c| {
let mut n = (**l).clone();
if let NodeKind::Layer { children, .. } = &mut n.kind {
*children = c;
}
Arc::new(n)
})
};
if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| layers.to_vec()).get_mut(i)?, new))) {
*slot.0 = slot.1;
}
}
out
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let expanded = expand_preset(s, C, p)?;
let p = &*expanded;
@@ -218,7 +362,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
}
/// `(path, value)` for every leaf of `v` that differs from `default` (`compression.color.ppi`).
fn changed(prefix: &str, v: &Value, default: &Value, out: &mut Vec<Value>) {
pub fn changed(prefix: &str, v: &Value, default: &Value, out: &mut Vec<Value>) {
match (v, default) {
(Value::Object(o), Value::Object(d)) => {
for (k, x) in o {
+15 -4
View File
@@ -10,7 +10,7 @@ use super::super::*;
use super::{err, source};
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text,
/// layers) are ignored by other formats.
/// layers) and the DXF ones are ignored by other formats.
#[derive(Clone, Debug, PartialEq)]
pub struct LoadOptions {
/// 1-based pages to import, such as `"2-3, 5"`; `None` = every page.
@@ -27,18 +27,28 @@ pub struct LoadOptions {
/// A PDF's optional content groups become layers (else one layer per page, without the art
/// that is off).
pub layers: bool,
/// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]).
pub dxf: vectorcraft_cad::ImportOptions,
}
impl Default for LoadOptions {
fn default() -> Self {
Self { pages: None, crop: CropTo::default(), password: None, color_mode: None, text_as: TextAs::default(), layers: true }
Self {
pages: None,
crop: CropTo::default(),
password: None,
color_mode: None,
text_as: TextAs::default(),
layers: true,
dxf: Default::default(),
}
}
}
impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`),
/// `textAs` (`text` | `outlines`) and `layers` (true | false).
/// `textAs` (`text` | `outlines`), `layers` (true | false) and `dxf` (the DXF import options).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
None => None,
@@ -60,7 +70,8 @@ impl LoadOptions {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
};
Ok(Self { pages, crop, password, color_mode, text_as, layers })
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
Ok(Self { pages, crop, password, color_mode, text_as, layers, dxf })
}
/// Does the document read only part of the file, or read it differently (a page range, another
+146 -27
View File
@@ -13,7 +13,8 @@ use vectorcraft_doc::Document;
use super::super::*;
use super::{
Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded, write_file,
ArtboardPick, Encoded, Format, Loaded, encode_all, file_stem, format, format_for_name, load, read_file, with_compression_pref, write_encoded,
write_file,
};
use crate::{DocState, Prefs};
@@ -67,7 +68,7 @@ impl SaveMode {
pub struct SavePlan {
pub mode: SaveMode,
/// Where to write. `None` when no path is known (never saved, converted from an older version,
/// or a format other than the document's own): the caller asks for one or takes the bytes.
/// restored by Data Recovery, or a format other than the document's own): the caller asks for one or takes the bytes.
pub path: Option<String>,
pub format: &'static Format,
/// The format's options (only those its encoder reads).
@@ -178,7 +179,8 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
// None given: the ones the document was last saved with in this format.
let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given };
// Save writes the document's own file; the other modes only where they are told to.
let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && format.id == st.format).then(|| st.path.clone()).flatten());
let path =
path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten());
let stem = file_stem(st.path.as_deref().unwrap_or(&st.doc.title));
let ext = format.extensions[0];
let name = match mode {
@@ -197,10 +199,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
/// Does a save in `f` read option `key`? PDF (and a .ai file, a PDF) also reads its General
/// settings (`createLayers`…) and presets.
fn reads_option(f: &Format, key: &str) -> bool {
match is_pdf(f) {
true => super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key),
false => f.options.iter().any(|o| o.name == key),
}
f.options.iter().any(|o| o.name == key) || (is_pdf(f) && (super::pdf::OPTIONS.iter().any(|o| o.name == key) || super::pdf::is_setting(key)))
}
/// Formats that carry the whole native document: they lose nothing, and record links relative to
@@ -250,21 +249,79 @@ pub struct SaveJob {
params: Value,
/// The document when the job was made: what counts as saved once the file is written.
snapshot: Arc<Document>,
/// The artboards also saved to files of their own (`separateArtboards`).
boards: Vec<usize>,
}
/// The artboards a native or `.ai` save also writes to files of their own: with
/// `separateArtboards`, those `range` names (default all).
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
if !is_native(f) || !bool_or(p, "separateArtboards", false) {
return Ok(vec![]);
}
let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string);
let n = doc.artboards.len();
let picked = ArtboardPick { range, ..Default::default() }.resolve(n).map_err(|e| bad(cmd, e))?;
Ok(picked.unwrap_or_else(|| (0..n).collect()))
}
/// `doc` reduced to artboard `i` and the art on it (Save each artboard to a separate file):
/// objects whose bounds touch the artboard, in their layers; resources are kept.
fn artboard_doc(doc: &Document, i: usize) -> Document {
let mut d = doc.clone();
let Some(a) = doc.artboards.get(i) else { return d };
let r = a.rect;
d.artboards = vec![a.clone()];
// It opens fitted to its artboard, not at the master file's view.
d.last_view = None;
for layer in &mut d.layers {
keep_on(layer, r);
}
d
}
/// Keep in layer `n` (and its sublayers) only the objects touching `r`.
fn keep_on(n: &mut Arc<vectorcraft_doc::Node>, r: vectorcraft_geom::Rect) {
let touches = |b: vectorcraft_geom::Rect| b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0;
if let Some(children) = Arc::make_mut(n).children_mut() {
children.retain(|c| c.is_layer() || c.visual_bounds().is_some_and(touches));
for c in children.iter_mut().filter(|c| c.is_layer()) {
keep_on(c, r);
}
}
}
/// `doc` with empty layers: the pages of a `.ai` file saved without PDF content.
fn blank_pages(doc: &Document) -> Document {
let mut d = doc.clone();
for layer in &mut d.layers {
if let Some(children) = Arc::make_mut(layer).children_mut() {
children.clear();
}
}
d
}
/// Why a `.ai` file saved without PDF content looks empty elsewhere.
const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens it as before, other apps show empty pages";
/// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the
/// document's own). Bad options fail here, before anything is encoded.
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
let cmd = plan.mode.command();
let retargets = plan.retargets();
// The web build, without a clock, leaves the dates unless given one.
if plan.path.is_some()
&& retargets
&& plan.retargets()
&& let Some(at) = clock_date(s, "modified", plan.modified)
{
stamp_save_dates(s.doc_mut()?, at);
}
let st = s.doc()?;
job_for(s, s.doc()?, plan)
}
/// Snapshot `st` (any open document of `s`) for `plan`, without stamping dates (Data Recovery
/// snapshots documents that aren't active).
pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<SaveJob> {
let cmd = plan.mode.command();
let own = doc_to_save(st, plan.format);
// A native file records its links' paths relative to where it is written.
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p));
@@ -280,12 +337,20 @@ pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
params = with_compression_pref(&s.prefs, &params);
super::native::save_options(cmd, plan.format, &doc, &params)?;
}
let boards = separate_boards(cmd, plan.format, &doc, &params)?;
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
// A PDF of every artboard with the PDF options, always carrying the native document.
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
// Use Compression compresses the PDF's content.
if let Some(c) = o.get("compress").and_then(Value::as_bool) {
let compression = o.entry("compression").or_insert_with(|| json!({}));
if let Some(m) = compression.as_object_mut() {
m.insert("compressText".into(), json!(c));
}
}
}
Ok(SaveJob { plan, doc, params, snapshot: s.doc()?.doc.clone() })
Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone(), boards })
}
impl SaveJob {
@@ -293,23 +358,54 @@ impl SaveJob {
&self.plan
}
/// Encode the file and write it with `write` (the file, then the images an SVG links to,
/// beside it). Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?:
/// [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, linked?: [path…]}`.
pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let (cmd, plan, doc) = (self.plan.mode.command(), &self.plan, &*self.doc);
let mut enc = if plan.format.id == "ai" {
let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &self.params)?;
/// The document when the job was made (what counts as saved once the file is written).
pub(crate) fn snapshot(&self) -> &Arc<Document> {
&self.snapshot
}
/// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with
/// the warnings (what the format loses first, then the encoder's own notes: PDF options not
/// applied yet…).
pub fn encode(&self) -> Result<Encoded> {
let mut enc = self.encode_one(&self.doc)?;
for &b in &self.boards {
let one = self.encode_one(&artboard_doc(&self.doc, b))?;
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
}
let pdf_less = self.plan.format.id == "ai" && !bool_or(&self.params, "pdfCompatible", true);
let notes = fidelity_warning(self.plan.format).into_iter().chain(pdf_less.then(|| NOT_PDF_COMPATIBLE.to_string()));
enc.warnings.splice(0..0, notes);
Ok(enc)
}
/// Encode one file of `doc` in the job's format.
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
let enc = if f.id == "ai" {
// The pages (blank without PDF content), carrying the native document.
let pages = doc.without_edit_modes();
let pages = if bool_or(&self.params, "pdfCompatible", true) { pages } else { std::borrow::Cow::Owned(blank_pages(&pages)) };
let native = || super::native::ai_native(cmd, f, doc, &self.params);
let (bytes, warnings) = super::pdf::encode_carrying(cmd, &pages, &self.params, native)?;
Encoded { warnings, ..Encoded::one(bytes) }
} else {
encode_all(doc, plan.format.id, &self.params)?
encode_all(doc, f.id, &self.params)?
};
if enc.files.len() != 1 {
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
}
// What the format loses first, then the encoder's own notes (PDF options not applied yet…).
let mut warnings = std::mem::take(&mut enc.warnings);
warnings.splice(0..0, fidelity_warning(plan.format));
Ok(enc)
}
/// Encode the file and write it with `write` (the file, then its artboards' files and the
/// images an SVG links to, beside it). Without a path → `{dataBase64, bytes, format, name,
/// folder?, warnings, files?: [{name, dataBase64}] (every file, when there are several),
/// linked?: [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, files?:
/// [path…], linked?: [path…]}`.
pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let (plan, doc) = (&self.plan, &*self.doc);
let mut enc = self.encode()?;
let warnings = std::mem::take(&mut enc.warnings);
let extra = json!({ "format": plan.format.id, "warnings": warnings });
let Some(path) = &plan.path else {
let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?;
@@ -325,7 +421,11 @@ impl SaveJob {
}
let bytes = files.first().map_or(0, |f| f.1.len());
let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra);
if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) {
let (main, linked) = files.split_at(enc.files.len().min(files.len()));
if main.len() > 1 {
out["files"] = main.iter().map(|(p, _)| json!(p)).collect();
}
if !linked.is_empty() {
out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect();
}
Ok(out)
@@ -341,17 +441,31 @@ impl SaveJob {
st.format = self.plan.format.id;
st.save_options = self.plan.options;
st.converted = false;
st.recovered = false;
st.mark_saved_as(&self.snapshot);
}
}
/// [`SaveJob::finish`] for document `uid` of `s`, if it is still open; a file that became
/// its own also ends its Data Recovery copy.
pub fn complete(self, s: &mut Session, uid: u64) {
let saved = self.plan.retargets() && self.plan.path.is_some();
if let Some(st) = s.document_mut(uid) {
self.finish(st);
}
if saved {
crate::cmd::recovery::forget(s, uid);
}
}
}
/// Encode `plan` and write it with `write`, then make the file the document's own (see
/// [`SaveJob`]). Without a path the document is unchanged.
pub fn save_with(s: &mut Session, plan: SavePlan, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let job = save_job(s, plan)?;
let uid = s.doc()?.uid;
let out = job.write(write)?;
job.finish(s.doc_mut()?);
job.complete(s, uid);
Ok(out)
}
@@ -377,6 +491,9 @@ fn revert(s: &mut Session, _: &Value) -> Result<Value> {
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?;
doc.template = false;
// Back to the saved file: its recovery copy has nothing left to recover.
let uid = s.doc()?.uid;
crate::cmd::recovery::forget(s, uid);
s.replace_document(index, doc);
Ok(json!({ "path": path }))
}
@@ -386,7 +503,9 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "file.formatOptions";
let st = s.active();
let id = str_param(p, "format").or(st.map(|d| d.format)).unwrap_or("vectorcraft");
let f = format(id).filter(|f| f.write).ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
let f = format(id)
.filter(|f| f.write || SAVE_FORMATS.contains(&f.id))
.ok_or_else(|| bad(C, format!("`{id}` is no writable format (see document.formats)")))?;
let saved = st.filter(|d| d.format == f.id).map(|d| &d.save_options);
// A native save compresses as Use Compression says, unless told otherwise.
let prefs = with_compression_pref(&s.prefs, &json!({}));
@@ -409,7 +528,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 3 (2 or 1: for older VectorCraft versions, never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), native and .ai: separateArtboards?: false (also save each artboard of range?: \"1-3, 5\"|\"all\" (default) to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document), .ai: compress?: true (compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+96 -34
View File
@@ -1,7 +1,8 @@
//! File → Export → Export for Screens (`document.exportForScreens`): the chosen artboards (or the
//! whole document) in every format row at each row's size, into a folder and its sub-folders, or
//! returned for download (each file, or one ZIP); the presets the dialog offers; and the settings
//! the document remembers (`document.exportSettings`).
//! whole document, or the chosen assets of the Asset Export panel) in every format row at each
//! row's size, into a folder and its sub-folders, or returned for download (each file, or one ZIP);
//! the presets the dialog offers; and the settings the document remembers
//! (`document.exportSettings`), which the Asset Export panel shares ([`SHARED_KEYS`]).
use std::collections::HashSet;
use std::sync::Arc;
@@ -13,8 +14,8 @@ use vectorcraft_geom::Rect;
use super::super::*;
use super::encode::single_artboard;
use super::export::without_artboards;
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, writable, write_file};
use super::export::{isolated, without_artboards};
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, writable, write_file};
const C: &str = "document.exportForScreens";
@@ -160,7 +161,7 @@ impl Row {
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
}
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf") {
if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf"));
}
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
@@ -219,6 +220,10 @@ impl Row {
}
}
/// The settings the Asset Export panel shares with the dialog: what is exported, not which art
/// or where to.
pub(crate) const SHARED_KEYS: [&str; 6] = ["formats", "preset", "settings", "prefix", "subfolders", "antiAlias"];
/// What `document.exportForScreens` reads besides the formats.
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -232,6 +237,45 @@ struct ScreenParams {
subfolders: bool,
preset: Option<String>,
prefix: Option<String>,
/// Asset ids (Asset Export) to write instead of artboards.
assets: Option<Vec<u64>>,
}
impl ScreenParams {
fn of(p: &Value) -> Result<Self> {
if p.is_object() { Self::deserialize(p).map_err(|e| bad(C, e.to_string())) } else { Ok(Self::default()) }
}
}
/// The format rows `p` asks for (`formats`, else `preset`'s, else one PNG at 1x), each with the
/// format's `settings` and the top-level `antiAlias`.
fn rows_of(s: &Session, p: &Value, preset: Option<&str>) -> Result<Vec<Row>> {
let settings = p.get("settings").cloned().unwrap_or(Value::Null);
let rows: Vec<Value> = match (preset, p.get("formats")) {
(Some(_), Some(f)) if !f.is_null() => return Err(bad(C, "give formats or a preset, not both")),
(Some(id), _) => preset_rows(id).ok_or_else(|| bad(C, format!("preset `{id}`: mobile or density")))?,
(None, Some(Value::Array(rows))) => rows.clone(),
(None, None | Some(Value::Null)) => vec![json!({})],
(None, Some(v)) => return Err(bad(C, format!("formats must be an array of rows, not {v}"))),
};
let mut rows: Vec<Row> = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::<Result<_>>()?;
if rows.is_empty() {
return Err(bad(C, "no formats to export"));
}
// A top-level anti-aliasing mode applies to every row that doesn't name its own.
if let Some(aa) = p.get("antiAlias") {
for r in &mut rows {
if let Some(m) = r.options.as_object_mut() {
m.entry("antiAlias").or_insert_with(|| aa.clone());
}
}
}
Ok(rows)
}
/// Check the export settings in `p` (formats, preset, settings…) as an export would read them.
pub(crate) fn check_settings(s: &Session, p: &Value) -> Result<()> {
rows_of(s, p, ScreenParams::of(p)?.preset.as_deref()).map(|_| ())
}
/// The file names (and bytes) an export wrote, or the files it returns.
@@ -272,31 +316,23 @@ impl Output {
}
}
/// File → Export for Screens: every chosen artboard in every format row, one file each (a PDF holds
/// its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard, else the
/// bounds of all art). Artboards that share a name get `-2`, `-3`… instead of overwriting. The
/// params are remembered in the document (`document.exportSettings`).
/// File → Export for Screens: [`export`], with the params remembered in the document
/// (`document.exportSettings`).
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
let o: ScreenParams = if p.is_object() { ScreenParams::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ScreenParams::default() };
let settings = p.get("settings").cloned().unwrap_or(Value::Null);
let rows: Vec<Value> = match (o.preset.as_deref(), p.get("formats")) {
(Some(_), Some(f)) if !f.is_null() => return Err(bad(C, "give formats or a preset, not both")),
(Some(id), _) => preset_rows(id).ok_or_else(|| bad(C, format!("preset `{id}`: mobile or density")))?,
(None, Some(Value::Array(rows))) => rows.clone(),
(None, None | Some(Value::Null)) => vec![json!({})],
(None, Some(v)) => return Err(bad(C, format!("formats must be an array of rows, not {v}"))),
};
let mut rows: Vec<Row> = rows.iter().map(|r| Row::parse(s, r, &settings)).collect::<Result<_>>()?;
if rows.is_empty() {
return Err(bad(C, "no formats to export"));
}
// A top-level anti-aliasing mode applies to every row that doesn't name its own.
if let Some(aa) = p.get("antiAlias") {
for r in &mut rows {
if let Some(m) = r.options.as_object_mut() {
m.entry("antiAlias").or_insert_with(|| aa.clone());
}
}
let r = export(s, p)?;
remember(s, p);
Ok(r)
}
/// Every chosen artboard in every format row, one file each (a PDF holds its artboard alone), or
/// with `fullDocument` one file per row (a PDF of every artboard, else the bounds of all art), or
/// with `assets` each asset's art alone, cropped to it. Artboards (assets) that share a name get
/// `-2`, `-3`… instead of overwriting.
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
if o.full_document && o.assets.is_some() {
return Err(bad(C, "give assets or fullDocument, not both"));
}
let subfolders = o.subfolders || o.preset.is_some();
// A prefix names files, not folders.
@@ -347,6 +383,24 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
};
out.put(folder, o.zip, name, bytes)?;
}
} else if let Some(ids) = &o.assets {
let assets =
ids.iter().map(|id| doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))).collect::<Result<Vec<_>>>()?;
if assets.is_empty() {
return Err(bad(C, "no assets to export"));
}
for (a, stem) in assets.iter().zip(unique_file_names(assets.iter().map(|a| (a.name.as_str(), "Asset".to_string())))) {
let (d, bounds) = isolated(&doc, &doc.paint_order(a.nodes.iter().copied()), &a.name)
.ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
for r in &rows {
let name = name_of(r, &stem);
if !out.claim(&name) {
continue;
}
let bytes = encode(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
out.put(folder, o.zip, name, bytes)?;
}
}
} else {
let n = doc.artboards.len();
let boards = o.boards.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect());
@@ -365,7 +419,6 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
remember(s, p);
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
if o.zip {
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
@@ -387,17 +440,26 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Keep the params of a successful export in the document (without `zip`, a delivery detail), so
/// the dialog reopens on them. Not an undo step; the document has changed only if they differ.
/// Keep the params of a successful export in the document (without `zip`, a delivery detail, and
/// `assets`, a choice of art), so the dialog reopens on them.
fn remember(s: &mut Session, p: &Value) {
let Some(o) = p.as_object() else { return };
let mut settings = o.clone();
settings.remove("zip");
let Ok(st) = s.doc_mut() else { return };
settings.remove("assets");
// Only after a successful export, which had a document.
let _ = store_settings(s, settings);
}
/// Make `settings` the document's export settings. Not an undo step (like the settings of any
/// export); the document has changed only if they differ.
pub(crate) fn store_settings(s: &mut Session, settings: Map<String, Value>) -> Result<()> {
let st = s.doc_mut()?;
if st.doc.export_settings != settings {
Arc::make_mut(&mut st.doc).export_settings = settings;
st.revision += 1;
}
Ok(())
}
/// `document.exportSettings`: the Export for Screens settings the document last exported with.
+27 -3
View File
@@ -146,7 +146,8 @@ fn open_exts_cover_every_readable_format() {
}
let filters: Vec<_> = open_filters().collect();
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(filters.len(), 4 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener presets and PDF presets");
assert_eq!(filters.len(), 6 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets, plug-ins");
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
}
#[test]
@@ -154,7 +155,30 @@ fn formats_query_lists_readers_writers_and_options() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf"]);
assert_eq!(
ids("writable"),
[
"vectorcraft",
"svg",
"svgz",
"pdf",
"png",
"jpg",
"gif",
"webp",
"tiff",
"bmp",
"template",
"png8",
"txt",
"dxf",
"eps",
"emf",
"wmf",
"tga",
"psd"
]
);
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert_eq!(png["options"]["scale"]["default"], 1);
@@ -209,7 +233,7 @@ fn export_without_path_returns_bytes_and_never_retargets() {
let r = s.execute("document.export", &json!({"format": "jpg"})).unwrap();
assert_eq!(&b64(&r)[..2], [0xFF, 0xD8]);
assert!(r.get("path").is_none());
assert!(s.execute("document.export", &json!({"path": dir.join("x.bmp").to_string_lossy()})).is_err(), "BMP is read-only");
assert!(s.execute("document.export", &json!({"path": dir.join("x.dwg").to_string_lossy()})).is_err(), "DWG can't be written");
// A never-saved document hands its native bytes back.
let mut s = session(10.0, 10.0, 1);
let r = s.execute("document.save", &json!({})).unwrap();
+2 -1
View File
@@ -150,7 +150,8 @@ fn formats_list_dxf_and_what_can_not_be_written() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
let dxf = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "dxf").unwrap().clone();
assert_eq!((dxf["read"].as_bool(), dxf["write"].as_bool()), (Some(false), Some(true)));
// Read too since DXF import (tests_dxfimport).
assert_eq!((dxf["read"].as_bool(), dxf["write"].as_bool()), (Some(true), Some(true)));
for o in [
"version",
"unit",
@@ -0,0 +1,243 @@
//! DXF through the commands: `document.open` with the DXF options, `document.dxfInfo`,
//! File → Place of a drawing (fitted, centred or at its origin), the format table, binary files,
//! and a drawing `document.export` wrote opened again.
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Rect;
use super::*;
fn b64(bytes: &[u8]) -> String {
vectorcraft_format::base64_encode(bytes)
}
/// A millimetre drawing: a 200 × 100 rectangle on layer Walls, a circle on layer Notes, a block
/// "Mark" (a short line) inserted twice, and a paper layout holding a line.
fn plan() -> Vec<u8> {
let pairs: &[(i32, &str)] = &[
(0, "SECTION"),
(2, "HEADER"),
(9, "$ACADVER"),
(1, "AC1015"),
(9, "$INSUNITS"),
(70, "4"),
(0, "ENDSEC"),
(0, "SECTION"),
(2, "TABLES"),
(0, "TABLE"),
(2, "LAYER"),
(0, "LAYER"),
(2, "Walls"),
(70, "0"),
(62, "1"),
(0, "LAYER"),
(2, "Notes"),
(70, "4"),
(62, "5"),
(0, "ENDTAB"),
(0, "ENDSEC"),
(0, "SECTION"),
(2, "BLOCKS"),
(0, "BLOCK"),
(2, "Mark"),
(10, "0"),
(20, "0"),
(0, "LINE"),
(8, "0"),
(10, "0"),
(20, "0"),
(11, "10"),
(21, "0"),
(0, "ENDBLK"),
(0, "ENDSEC"),
(0, "SECTION"),
(2, "ENTITIES"),
(0, "LWPOLYLINE"),
(8, "Walls"),
(90, "4"),
(70, "1"),
(10, "0"),
(20, "0"),
(10, "200"),
(20, "0"),
(10, "200"),
(20, "100"),
(10, "0"),
(20, "100"),
(0, "CIRCLE"),
(8, "Notes"),
(10, "100"),
(20, "50"),
(40, "20"),
(0, "INSERT"),
(8, "Walls"),
(2, "Mark"),
(10, "20"),
(20, "20"),
(0, "INSERT"),
(8, "Walls"),
(2, "Mark"),
(10, "150"),
(20, "20"),
(0, "LINE"),
(67, "1"),
(10, "0"),
(20, "0"),
(11, "50"),
(21, "0"),
(0, "ENDSEC"),
(0, "EOF"),
];
pairs.iter().map(|(c, v)| format!("{c:>3}\n{v}\n")).collect::<String>().into_bytes()
}
fn open(s: &mut Session, bytes: &[u8], extra: Value) -> crate::Result<Value> {
let mut p = json!({"name": "plan.dxf", "dataBase64": b64(bytes)});
if let (Some(p), Value::Object(e)) = (p.as_object_mut(), extra) {
p.extend(e);
}
s.execute("document.open", &p)
}
fn layer_names(s: &Session) -> Vec<String> {
s.doc().unwrap().doc.layers.iter().filter_map(|l| l.name.clone()).collect()
}
fn leaves(s: &Session) -> Vec<Node> {
let mut out = vec![];
s.doc().unwrap().doc.walk(|n| {
if n.children().is_none() {
out.push(n.clone());
}
});
out
}
const MM: f64 = 72.0 / 25.4;
#[test]
fn document_open_reads_a_dxf_drawing() {
let mut s = Session::new();
let r = open(&mut s, &plan(), json!({})).unwrap();
assert_eq!((r["format"].as_str(), r["title"].as_str()), (Some("dxf"), Some("plan.dxf")));
assert_eq!(layer_names(&s), ["Walls", "Notes"]);
let st = s.doc().unwrap();
// Millimetres at 1:1, the artboard the art's size.
let board = st.doc.artboards[0].rect;
assert!((board.width() - 200.0 * MM).abs() < 1e-6 && (board.height() - 100.0 * MM).abs() < 1e-6, "{board:?}");
assert_eq!(st.doc.units, vectorcraft_doc::Unit::Millimeters);
assert_eq!(st.doc.symbols.len(), 1, "the block is a symbol");
assert_eq!(leaves(&s).iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count(), 2);
assert!(st.doc.layers[1].locked, "Notes is locked");
// Saving asks for a name: DXF opens as artwork, it isn't written back.
assert!(st.path.is_none());
}
#[test]
fn document_open_takes_the_dxf_options() {
let mut s = Session::new();
open(&mut s, &plan(), json!({"dxf": {"fit": true, "mergeLayers": true}})).unwrap();
assert_eq!(layer_names(&s), ["Layer 1"]);
let board = s.doc().unwrap().doc.artboards[0].rect;
assert_eq!((board.width(), board.height()), (792.0, 612.0), "fitted to a landscape letter page");
open(&mut s, &plan(), json!({"dxf": {"unit": "cm", "scale": 1}})).unwrap();
let board = s.doc().unwrap().doc.artboards[0].rect;
assert!((board.width() - 200.0 * MM * 10.0).abs() < 1e-6, "1 cm = 1 unit: {board:?}");
open(&mut s, &plan(), json!({"dxf": {"layout": "Layout1"}})).unwrap();
assert_eq!(leaves(&s).len(), 1, "the paper layout's line");
for bad in [json!({"dxf": {"unit": "furlong"}}), json!({"dxf": {"scale": 0}}), json!({"dxf": {"layout": "Sheet 9"}}), json!({"dxf": 3})] {
assert!(open(&mut s, &plan(), bad.clone()).is_err(), "{bad}");
}
}
#[test]
fn dxf_info_lists_layouts_layers_and_units() {
let mut s = Session::new();
let r = s.execute("document.dxfInfo", &json!({"dataBase64": b64(&plan())})).unwrap();
assert_eq!(r["version"], "2000");
assert_eq!(r["units"], "Millimeters");
assert_eq!(r["layouts"], json!(["Model", "Layout1"]));
assert_eq!(r["layers"], json!(["Walls", "Notes"]));
assert_eq!((r["unit"].as_str(), r["scale"].as_f64()), (Some("Millimeters"), Some(1.0)));
}
#[test]
fn binary_dxf_says_to_save_it_as_ascii() {
let mut s = Session::new();
let mut bytes = vectorcraft_cad::import::BINARY_SENTINEL.to_vec();
bytes.extend([0u8; 64]);
let e = open(&mut s, &bytes, json!({})).unwrap_err().to_string();
assert!(e.contains("binary DXF") && e.contains("ASCII"), "{e}");
let e = s.execute("document.dxfInfo", &json!({"dataBase64": b64(&bytes)})).unwrap_err().to_string();
assert!(e.contains("binary DXF"), "{e}");
}
#[test]
fn dxf_is_readable_and_detected_by_content() {
let f = format("dxf").unwrap();
assert!(f.read && f.write);
assert!(OPEN_EXTS.contains(&"dxf") && PLACE_EXTS.contains(&"dxf"));
assert_eq!(detect("drawing.txt", &plan()).map(|f| f.id), Some("dxf"), "the content tells");
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
assert!(r["readable"].as_array().unwrap().contains(&json!("dxf")));
}
/// A document of one 600 × 400 artboard.
fn page() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
s
}
fn placed(s: &mut Session, dxf: Value) -> (Value, Rect) {
let mut p = json!({"name": "plan.dxf", "dataBase64": b64(&plan())});
if !dxf.is_null() {
p["dxf"] = dxf;
}
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("file.place", &p).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let id = vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap());
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
(r, b)
}
#[test]
fn file_place_puts_a_drawing_in_as_one_group() {
let mut s = page();
let (r, b) = placed(&mut s, Value::Null);
assert_eq!(r["format"], "dxf");
let st = s.doc().unwrap();
let id = vectorcraft_doc::NodeId(r["ids"][0].as_u64().unwrap());
assert!(matches!(st.doc.node(id).unwrap().kind, NodeKind::Group { clip: false, .. }));
assert_eq!(st.doc.symbols.len(), 1, "its symbol joins the document");
// Centred on the artboard at 1:1.
assert!((b.center().x - 300.0).abs() < 0.5 && (b.center().y - 200.0).abs() < 0.5, "{b:?}");
assert!((b.width() - 200.0 * MM).abs() < 1.0, "{b:?}");
// Fit: the artboard's size.
let (_, b) = placed(&mut s, json!({"fit": true}));
assert!((b.width() - 600.0).abs() < 1.0 && (b.center().y - 200.0).abs() < 0.5, "{b:?}");
// Not centred: the drawing's origin on the artboard's bottom-left corner.
let (_, b) = placed(&mut s, json!({"center": false, "unit": "pt", "scale": 1}));
assert!((b.x0 - 0.0).abs() < 0.5 && (b.y1 - 400.0).abs() < 0.5, "{b:?}");
}
#[test]
fn a_drawing_export_writes_opens_again() {
let mut s = page();
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap();
s.execute("shape.ellipse", &json!({"x": 350, "y": 100, "width": 100, "height": 100})).unwrap();
let r = s.execute("document.export", &json!({"format": "dxf", "unit": "pt"})).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let r = open(&mut s, &bytes, json!({"dxf": {"unit": "pt", "scale": 1, "center": false}})).unwrap();
assert_eq!(r["format"], "dxf");
let all: Vec<Rect> = leaves(&s).iter().filter_map(|n| n.geometric_bounds()).collect();
assert_eq!(all.len(), 2, "{all:?}");
let h = s.doc().unwrap().doc.artboards[0].rect.height();
// The export's origin is the artboard's bottom-left: the art keeps its place above it.
let union = all.iter().copied().reduce(|a, b| a.union(b)).unwrap();
assert!((union.width() - 350.0).abs() < 2.0 && (union.y1 - h + 200.0).abs() < 2.0, "{union:?} on {h}");
}
+206
View File
@@ -0,0 +1,206 @@
//! EPS export through the commands: the art's bounds or one file per artboard, flattened
//! transparency, previews, colours, options and the document the file carries.
use serde_json::{Value, json};
use super::*;
fn session(width: f64, height: f64, artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// The PostScript of an EPS file (behind its preview header, if any).
fn postscript(bytes: &[u8]) -> String {
String::from_utf8(vectorcraft_eps::sections(bytes).unwrap().0.to_vec()).unwrap()
}
fn dsc(ps: &str, key: &str) -> Option<String> {
let prefix = format!("%%{key}:");
ps.lines().find_map(|l| l.strip_prefix(&prefix)).map(|v| v.trim().to_string())
}
/// The first value of TIFF tag `tag` (a short or a long, in the first directory).
fn tiff_tag(tiff: &[u8], tag: u16) -> Option<u32> {
let u16_at = |at: usize| Some(u16::from_le_bytes(tiff.get(at..at + 2)?.try_into().ok()?));
let dir = u32::from_le_bytes(tiff.get(4..8)?.try_into().ok()?) as usize;
let entry = (0..u16_at(dir)? as usize).map(|i| dir + 2 + i * 12).find(|e| u16_at(*e) == Some(tag))?;
match u16_at(entry + 2)? {
3 => u16_at(entry + 8).map(u32::from),
_ => Some(u32::from_le_bytes(tiff.get(entry + 8..entry + 12)?.try_into().ok()?)),
}
}
fn warnings(r: &Value) -> Vec<String> {
r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect()
}
#[test]
fn eps_covers_the_art_y_up_from_the_first_artboard_with_a_preview_and_the_document() {
let mut s = session(200.0, 200.0, 1);
s.execute("shape.rectangle", &json!({"x": 50, "y": 60, "width": 100, "height": 40})).unwrap();
let r = s.execute("document.export", &json!({"format": "eps"})).unwrap();
assert_eq!(r["format"], "eps");
let bytes = b64(&r);
// The default TIFF preview sits behind the binary header.
let (_, tiff) = vectorcraft_eps::sections(&bytes).unwrap();
let preview = image::load_from_memory_with_format(tiff.unwrap(), image::ImageFormat::Tiff).unwrap();
assert_eq!((preview.width(), preview.height()), (100, 40));
let ps = postscript(&bytes);
assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("50 100 150 140"), "60..100 down is 100..140 up");
assert_eq!(dsc(&ps, "LanguageLevel").as_deref(), Some("3"));
assert!(ps.contains("[1 0 0 -1 0 200] cm"));
// RGB documents are written in CMYK by default.
assert!(!ps.contains(" rg\n") && ps.contains(" k\n"), "{ps}");
// The document comes back whole, and so does a thumbnail.
let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap();
assert_eq!(native.layers[0].children().unwrap().len(), 1);
assert!(vectorcraft_eps::thumbnail(&bytes).unwrap().starts_with(b"\x89PNG"));
// Without the preview, thumbnail and CMYK: plain PostScript in RGB.
let r = s.execute("document.exportEps", &json!({"previewFormat": "none", "thumbnails": false, "cmykPostScript": false, "level": 2})).unwrap();
let bytes = b64(&r);
assert!(bytes.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0"));
let ps = postscript(&bytes);
assert!(ps.contains(" rg\n") && vectorcraft_eps::thumbnail(&bytes).is_none());
assert_eq!(dsc(&ps, "LanguageLevel").as_deref(), Some("2"));
}
#[test]
fn use_artboards_writes_a_file_per_artboard_named_with_an_underscore() {
let mut s = session(100.0, 100.0, 2);
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
let r = s.execute("document.export", &json!({"format": "eps", "useArtboards": true, "previewFormat": "none"})).unwrap();
let files = r["files"].as_array().unwrap();
let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Untitled-1_Artboard-1.eps", "Untitled-1_Artboard-2.eps"]);
for f in files {
let bytes = b64(f);
let ps = postscript(&bytes);
assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("0 0 100 100"));
// Each carries the document with its own artboard.
let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap();
assert_eq!(native.artboards.len(), 1);
}
// A range names the artboards (useArtboards implied); one file keeps the path's name.
let r = s.execute("document.exportEps", &json!({"range": "2", "previewFormat": "none"})).unwrap();
assert!(r.get("files").is_none());
assert!(postscript(&b64(&r)).contains(&format!("[1 0 0 -1 {} 100] cm", -second)));
assert!(s.execute("document.exportEps", &json!({"artboard": 5})).is_err());
// Without art there is nothing to bound.
let mut empty = session(100.0, 100.0, 1);
assert!(empty.execute("document.exportEps", &json!({})).unwrap_err().to_string().contains("nothing to export"));
}
#[test]
fn transparency_is_flattened_with_the_flattener_preset() {
let mut s = session(200.0, 200.0, 1);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 100, "height": 100})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 60, "y": 60, "width": 100, "height": 100})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
let opts = json!({"previewFormat": "none", "thumbnails": false, "cmykPostScript": false});
let r = s.execute("document.exportEps", &opts).unwrap();
let w = warnings(&r);
assert!(w.iter().any(|w| w.contains("flattened")), "{w:?}");
assert!(!w.iter().any(|w| w.contains("written opaque")), "nothing transparent reaches the writer: {w:?}");
let ps = postscript(&b64(&r));
// Flat regions: red alone, the half-transparent blue over red, and over white.
let colours: Vec<&str> = ps.lines().filter(|l| l.ends_with(" rg")).collect();
assert_eq!(colours, ["0.5 0.5 1 rg", "1 0 0 rg", "0.5 0 0.5 rg"], "{ps}");
// The document is left as it was, and reopens with its transparency.
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.layers[0].children().unwrap().len(), 2);
let native = vectorcraft_format::load(&vectorcraft_eps::native(&b64(&r)).unwrap()).unwrap();
assert!(native.layers[0].children().unwrap().iter().any(|c| c.opacity < 1.0));
// A saved preset by name; an unknown one is refused.
s.execute("flattener.presets.save", &json!({"name": "All Raster", "balance": 0})).unwrap();
let r = s.execute("document.exportEps", &json!({"flattenerPreset": "All Raster", "previewFormat": "none"})).unwrap();
assert!(postscript(&b64(&r)).contains("image"), "rasterized");
let e = s.execute("document.exportEps", &json!({"flattenerPreset": "Nope"})).unwrap_err().to_string();
assert!(e.contains("preset"), "{e}");
}
#[test]
fn overprints_are_kept_or_discarded() {
let mut s = session(100.0, 100.0, 1);
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap();
s.execute("attributes.set", &json!({"ids": [id], "overprintFill": true})).unwrap();
let kept = postscript(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap()));
assert!(kept.contains("true op\n"), "{kept}");
let dropped = postscript(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none", "overprints": "discard"})).unwrap()));
assert!(!dropped.contains("true op"));
}
#[test]
fn placed_images_are_written_and_previews_are_black_and_white_on_request() {
let mut s = super::tests_svg::image_session();
let r = s.execute("document.exportEps", &json!({"previewFormat": "tiffBw", "level": 2})).unwrap();
let bytes = b64(&r);
let ps = postscript(&bytes);
assert!(ps.contains("/RunLengthDecode filter >> image"), "{ps}");
let tiff = vectorcraft_eps::sections(&bytes).unwrap().1.unwrap();
// A bilevel TIFF: one sample of one bit, white is zero.
assert!(tiff.starts_with(b"II*\0"));
assert_eq!((tiff_tag(tiff, 258), tiff_tag(tiff, 277), tiff_tag(tiff, 262)), (Some(1), Some(1), Some(0)));
let l3 = postscript(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap()));
assert!(l3.contains("/FlateDecode filter >> image"));
}
#[test]
fn bad_eps_options_are_refused_and_formats_list_eps() {
let mut s = session(100.0, 100.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
for p in [
json!({"level": 1}),
json!({"level": "4"}),
json!({"previewFormat": "pict"}),
json!({"overprints": "simulate"}),
json!({"flattener": {"balance": 400}}),
json!({"thumbnails": "yes"}),
] {
let e = s.execute("document.exportEps", &p).unwrap_err().to_string();
assert!(e.contains("EPS"), "{p}: {e}");
}
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "eps"}]})).is_err());
let r = s.execute("document.formats", &json!({})).unwrap();
let eps = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "eps").unwrap().clone();
// Readable too since EPS import (tests_epsimport.rs).
assert_eq!((eps["read"].as_bool(), eps["write"].as_bool(), eps["mime"].as_str()), (Some(true), Some(true), Some("application/postscript")));
for o in [
"level",
"previewFormat",
"transparentPreview",
"overprints",
"flattenerPreset",
"flattener",
"embedFonts",
"includeLinkedFiles",
"thumbnails",
"cmykPostScript",
"compatibleGradients",
"selectedOnly",
"useArtboards",
] {
assert!(eps["options"].get(o).is_some(), "{o}");
}
}
#[test]
fn selected_only_writes_the_selection() {
let mut s = session(200.0, 200.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
let b = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let ps = postscript(&b64(&s.execute("document.exportEps", &json!({"selectedOnly": true, "previewFormat": "none"})).unwrap()));
assert_eq!(dsc(&ps, "BoundingBox").as_deref(), Some("100 80 150 100"));
}
@@ -0,0 +1,121 @@
//! EPS and PostScript files opened and placed: our own EPS files restore the document they carry,
//! others are read by the PostScript interpreter or come in as their preview.
use serde_json::{Value, json};
use super::tests_svgedit::{comparable, rich};
use super::*;
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap_or_else(|| panic!("no dataBase64 in {v}"))).unwrap()
}
fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Result<Value> {
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}))
}
/// `bytes` with `from` replaced by `to` (the same length: the preview header's offsets stay).
fn patched(bytes: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
assert_eq!(from.len(), to.len());
let at = bytes.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no {:?}", String::from_utf8_lossy(from)));
let mut out = bytes.to_vec();
out[at..at + to.len()].copy_from_slice(to);
out
}
/// An EPS file of ours as another app would write it: without the document it carries.
fn foreign(eps: &[u8]) -> Vec<u8> {
patched(eps, b"%VectorCraft_BeginData: native", b"%VectorCraft_BeginData: nativx")
}
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, Vec::len)).sum()
}
#[test]
fn an_eps_file_of_ours_reopens_as_the_same_document() {
let mut s = rich();
let before = comparable(&s.doc().unwrap().doc);
let eps = b64(&s.execute("document.exportEps", &json!({})).unwrap());
let r = open(&mut s, "rich.eps", &eps).unwrap();
assert_eq!((&r["warnings"], &r["restored"], &r["format"]), (&json!([]), &json!(true), &json!("eps")), "{r}");
assert_eq!(comparable(&s.doc().unwrap().doc), before, "the document came back exactly");
assert_eq!(s.doc().unwrap().path, None, "an EPS file is not saved over");
// A document that can't be read: the PostScript is read instead, with a warning.
let at = eps.windows(9).position(|w| w == b"native\n% ").unwrap() + 12;
let mut damaged = eps.clone();
damaged[at] = b'z';
let r = open(&mut s, "damaged.eps", &damaged).unwrap();
assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_DAMAGED), &json!(false)), "{r}");
assert!(objects(&s) > 0);
}
#[test]
fn other_eps_files_are_read_by_the_postscript_interpreter() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap();
let eps = foreign(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "none", "cmykPostScript": false})).unwrap()));
let r = open(&mut s, "plain.eps", &eps).unwrap();
assert_eq!((&r["restored"], &r["format"]), (&json!(false), &json!("eps")), "{r}");
let d = &s.doc().unwrap().doc;
// The page is the bounding box: the art's bounds.
assert_eq!(d.artboards[0].rect.size(), vectorcraft_geom::Size::new(101.0, 51.0), "the stroke's half width rounds up to whole points");
assert_eq!(objects(&s), 1);
assert_eq!(s.doc().unwrap().title(), "plain.eps");
}
#[test]
fn an_unreadable_eps_file_comes_in_as_its_preview() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("shape.ellipse", &json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap();
let eps = foreign(&b64(&s.execute("document.exportEps", &json!({"previewFormat": "tiffColor"})).unwrap()));
// An operator the interpreter doesn't know (another app's own).
let eps = patched(&eps, b"showpage", b"frobnica");
let r = open(&mut s, "art.eps", &eps).unwrap();
let w = r["warnings"][0].as_str().unwrap();
assert!(w.contains("frobnica") && w.contains("preview"), "{r}");
let d = &s.doc().unwrap().doc;
let layer = &d.layers[0];
let image = &layer.children().unwrap()[0];
assert!(matches!(image.kind, vectorcraft_doc::NodeKind::Image(_)), "{:?}", image.kind);
assert_eq!(image.geometric_bounds().unwrap(), d.artboards[0].rect);
}
#[test]
fn postscript_ai_files_and_garbage() {
let mut s = Session::new();
let ps = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n";
// A PostScript .ai opens as artwork; Save asks where (the file isn't written back).
let r = open(&mut s, "legacy.ai", ps).unwrap();
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
assert_eq!(s.doc().unwrap().path, None);
assert_eq!(objects(&s), 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}");
// Garbage named .eps says what it is.
let e = open(&mut s, "junk.eps", b"not postscript at all").unwrap_err().to_string();
assert!(e.contains("junk.eps") && e.contains("PostScript"), "{e}");
let e = open(&mut s, "junk.eps", &[0xC5, 0xD0, 0xD3, 0xC6, 1, 2, 3]).unwrap_err().to_string();
assert!(e.contains("junk.eps"), "{e}");
}
#[test]
fn eps_files_place_as_one_group_clipped_to_their_bounding_box() {
let mut s = rich();
let eps = b64(&s.execute("document.exportEps", &json!({"useArtboards": true, "artboards": [0]})).unwrap());
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
let r = s.execute("file.place", &json!({"name": "rich.eps", "dataBase64": vectorcraft_format::base64_encode(&eps), "at": [300, 300]})).unwrap();
assert_eq!(r["format"], "eps", "{r}");
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
// The interpreter's reading of the same file places too.
let r = s.execute("file.place", &json!({"name": "other.eps", "dataBase64": vectorcraft_format::base64_encode(&foreign(&eps))})).unwrap();
assert_eq!(r["ids"].as_array().unwrap().len(), 1, "{r}");
let info = s.execute("file.place.info", &json!({"name": "other.eps", "dataBase64": vectorcraft_format::base64_encode(&foreign(&eps))})).unwrap();
assert_eq!(info["format"], "eps");
// EPS is readable, and listed for the open and place dialogs.
assert!(format("eps").is_some_and(|f| f.read && f.write));
assert!(OPEN_EXTS.contains(&"eps") && PLACE_EXTS.contains(&"eps"));
}
@@ -0,0 +1,160 @@
//! EMF and WMF through the commands: export of one artboard or each, raster effects as images,
//! open, place, paste, and the format lists.
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use super::*;
fn session(width: f64, height: f64, artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
s
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
fn u32_at(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
fn i32_at(b: &[u8], at: usize) -> i32 {
i32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
/// The EMF's record types.
fn emf_kinds(b: &[u8]) -> Vec<u32> {
let mut out = vec![];
let mut at = 0;
while at + 8 <= b.len() {
out.push(u32_at(b, at));
at += u32_at(b, at + 4) as usize;
}
out
}
/// Two 100 pt artboards side by side, a red rectangle on each.
fn two_boards() -> Session {
let mut s = session(100.0, 100.0, 2);
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
s
}
#[test]
fn export_writes_an_emf_or_wmf_of_one_artboard() {
let mut s = two_boards();
let r = s.execute("document.export", &json!({"format": "emf"})).unwrap();
assert_eq!(r["format"], "emf");
let emf = b64(&r);
assert_eq!(vectorcraft_metafile::sniff(&emf), Some(vectorcraft_metafile::Kind::Emf));
// The first artboard's frame in 0.01 mm.
let side = (100.0 * 2540.0 / 72.0f64).round() as i32;
assert_eq!([i32_at(&emf, 24), i32_at(&emf, 28), i32_at(&emf, 32), i32_at(&emf, 36)], [0, 0, side, side]);
assert_eq!(emf_kinds(&emf).iter().filter(|k| **k == 62).count(), 1, "one rectangle on it");
// The second artboard alone; serialize gives the same bytes.
let second = b64(&s.execute("document.export", &json!({"format": "emf", "artboard": 1})).unwrap());
assert_eq!(emf_kinds(&second).iter().filter(|k| **k == 62).count(), 1);
assert_eq!(b64(&s.execute("document.serialize", &json!({"format": "emf"})).unwrap()), emf);
let wmf = b64(&s.execute("document.export", &json!({"format": "wmf"})).unwrap());
assert_eq!(vectorcraft_metafile::sniff(&wmf), Some(vectorcraft_metafile::Kind::Wmf));
assert_eq!(writable_format(None, Some("x/pic.wmf")).unwrap().id, "wmf");
assert_eq!(writable_format(Some("EMF"), None).unwrap().id, "emf");
// One file holds one artboard.
assert!(s.execute("document.export", &json!({"format": "emf", "artboards": [0, 1]})).is_err());
}
#[test]
fn use_artboards_writes_one_picture_each_or_the_art() {
let mut s = two_boards();
let r = s.execute("document.export", &json!({"format": "wmf", "useArtboards": true})).unwrap();
let files = r["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert!(files[0]["name"].as_str().unwrap().ends_with("-Artboard-1.wmf"), "{files:?}");
// Without artboards: the bounds of the art (both rectangles).
let r = s.execute("document.export", &json!({"format": "emf", "useArtboards": false})).unwrap();
let emf = b64(&r);
let width_pt = f64::from(i32_at(&emf, 32)) / 2540.0 * 72.0;
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
assert!((width_pt - (second + 30.0)).abs() < 0.05, "{width_pt}");
}
#[test]
fn raster_effects_are_written_as_images() {
let mut s = session(200.0, 200.0, 1);
let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 60, "height": 60})).unwrap()["id"].clone();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
let r = s.execute("document.export", &json!({"format": "emf"})).unwrap();
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("raster effects")), "{r}");
let kinds = emf_kinds(&b64(&r));
assert!(kinds.contains(&114), "the shadow is an image with transparency");
}
#[test]
fn metafiles_open_and_place() {
let mut s = two_boards();
let emf = b64(&s.execute("document.export", &json!({"format": "emf"})).unwrap());
let wmf = b64(&s.execute("document.export", &json!({"format": "wmf"})).unwrap());
for (name, bytes) in [("pic.emf", &emf), ("pic.wmf", &wmf)] {
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
assert_eq!(r["format"], &name[4..]);
assert_eq!(r["warnings"], json!([]));
let st = s.doc().unwrap();
let ab = st.doc.artboards[0].rect;
assert!((ab.width() - 100.0).abs() < 0.05 && (ab.height() - 100.0).abs() < 0.05, "{ab:?}");
let fill = st.doc.layers[0].children().unwrap().iter().find(|n| matches!(n.kind, NodeKind::Path { .. })).unwrap().clone();
let b = fill.geometric_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 0.05 && (b.width() - 30.0).abs() < 0.05, "{b:?}");
// Save can't write the file back: the document has no path.
assert!(s.doc().unwrap().path.is_none());
}
// Placed: one group clipped to the picture's frame, named after the file.
let mut s = session(300.0, 300.0, 1);
let r = s.execute("file.place", &json!({"name": "pic.emf", "dataBase64": vectorcraft_format::base64_encode(&emf), "at": [150, 150]})).unwrap();
assert_eq!(r["format"], "emf");
assert!((r["width"].as_f64().unwrap() - 100.0).abs() < 0.05);
let st = s.doc().unwrap();
let placed = st.doc.layers[0].children().unwrap().last().unwrap().clone();
assert_eq!(placed.name.as_deref(), Some("pic.emf"));
assert!(matches!(placed.kind, NodeKind::Group { clip: true, .. }));
let ext = fileio::PLACE_EXTS;
assert!(ext.contains(&"emf") && ext.contains(&"wmf") && OPEN_EXTS.contains(&"emf") && OPEN_EXTS.contains(&"wmf"));
}
#[test]
fn formats_list_emf_and_wmf() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
for k in ["readable", "writable"] {
let ids: Vec<&str> = r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert!(ids.contains(&"emf") && ids.contains(&"wmf"), "{k}: {ids:?}");
}
let emf = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "emf").unwrap();
assert_eq!(emf["mime"], "image/emf");
assert!(emf["options"]["useArtboards"].is_object());
assert!(open_filters().any(|(label, ext)| label == "EMF" && ext == ["emf"]));
assert!(place_filters().any(|(label, ext)| label == "WMF" && ext == ["wmf"]));
// Export for Screens writes the screen formats only.
let mut s = two_boards();
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "emf"}]})).is_err());
}
#[test]
fn a_pasted_metafile_becomes_objects() {
let mut src = two_boards();
let emf = b64(&src.execute("document.export", &json!({"format": "emf"})).unwrap());
let mut s = session(300.0, 300.0, 1);
let r = s.execute("clipboard.importEmf", &json!({"dataBase64": vectorcraft_format::base64_encode(&emf), "center": [150, 150]})).unwrap();
assert_eq!(r["count"], 1);
s.execute("edit.paste", &json!({"center": [150, 150]})).unwrap();
let pasted = s.doc().unwrap().doc.layers[0].children().unwrap().clone();
assert_eq!(pasted.len(), 1);
let b = pasted[0].geometric_bounds().unwrap();
assert!((b.center().x - 150.0).abs() < 0.05 && (b.width() - 30.0).abs() < 0.05, "{b:?}");
assert!(s.execute("clipboard.importEmf", &json!({"dataBase64": vectorcraft_format::base64_encode(b"%PDF-1.7")})).is_err());
}
+12 -7
View File
@@ -60,17 +60,20 @@ fn export_pdf_writes_the_compatibility_header_and_the_range() {
fn bad_options_are_refused() {
let mut s = session(2);
for p in [
json!({"compatibility": "1.3"}),
json!({"compatibility": "1.2"}),
json!({"compatibility": "1.3", "standard": "pdfA2b"}),
json!({"compatibility": "1.3", "flattenerPreset": "Nope"}),
json!({"compatibility": 1.5}),
json!({"range": "1-3"}),
json!({"range": "0"}),
json!({"artboards": []}),
json!({"preset": "Nope"}),
json!({"standard": "pdfX4"}),
json!({"standard": "pdfX4", "compatibility": "1.7"}),
json!({"standard": "pdfA2b", "compatibility": "2.0"}),
json!({"compression": {"color": {"ppi": 0}}}),
json!({"compression": "zip"}),
json!({"security": {"openPassword": "x"}}),
json!({"standard": "pdfA2b", "security": {"openPassword": "x"}}),
json!({"security": {"openPassword": "x", "permissionsPassword": "x"}}),
] {
let e = s.execute("document.exportPdf", &p).unwrap_err();
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}");
@@ -104,9 +107,11 @@ fn pdf_a_refuses_pdf_2() {
#[test]
fn options_not_applied_yet_and_document_features_warn() {
let mut s = session(1);
let w = warnings(&export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}})));
assert_eq!(w.len(), 2, "{w:?}");
assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("fast web view")), "printer's marks are drawn: no warning");
// Printer's marks are drawn, thumbnails embedded and the file linearised: nothing to report.
let v = export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}}));
assert_eq!(warnings(&v), Vec::<String>::new());
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
assert!(text.contains("/Thumb ") && text.contains("/Linearized 1"));
// A pattern stroke is written as its tiles clipped to the stroke: nothing to report.
let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [tile]})).unwrap();
@@ -155,7 +160,7 @@ fn pdf_settings_summarise_changes_and_warnings() {
let changed: Vec<&str> = v["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect();
assert_eq!(changed, ["compatibility", "compression.compressText", "createLayers", "security.copy"], "key order");
assert_eq!(v["changed"][0]["value"], "1.5");
assert_eq!(v["warnings"].as_array().unwrap().len(), 2, "layers and permissions: {}", v["warnings"]);
assert_eq!(v["warnings"].as_array().unwrap().len(), 1, "permissions (PDF layers are written at 1.5): {}", v["warnings"]);
assert!(!v.to_string().contains("Password"), "passwords never come back");
assert!(s.execute("document.pdfSettings", &json!({"compatibility": "9"})).is_err());
// With the document: the in-memory export adds the document's own warnings.
+155
View File
@@ -0,0 +1,155 @@
//! `document.exportPdf {compatibility: "1.3", flattenerPreset?, flattener?}`: a PDF 1.3 file has
//! no transparency (no soft masks, blend modes, constant opacity or transparency groups): it is
//! flattened with the chosen preset, and reads back looking like the canvas.
use serde_json::{Value, json};
use vectorcraft_geom::Rect;
use vectorcraft_render::{Rendered, Renderer};
use super::*;
/// A document with overlapping transparency: a half-opaque rectangle, a Multiply ellipse, a
/// gradient fading out, a drop shadow and a PNG with see-through pixels, in an isolated page.
fn session() -> Session {
let mut s = Session::new();
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
run(&mut s, "file.new", json!({"width": 200, "height": 160}));
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 90, "height": 70}));
run(&mut s, "paint.setFill", json!({"color": "#2060e0"}));
run(&mut s, "shape.rectangle", json!({"x": 50, "y": 40, "width": 90, "height": 70}));
run(&mut s, "paint.setFill", json!({"color": "#f02010"}));
run(&mut s, "transparency.set", json!({"opacity": 50}));
run(&mut s, "shape.ellipse", json!({"x": 90, "y": 20, "width": 80, "height": 80}));
run(&mut s, "paint.setFill", json!({"color": "#20c040"}));
run(&mut s, "transparency.set", json!({"blendMode": "multiply"}));
let shadowed = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 100, "width": 50, "height": 40}))["id"].clone();
run(&mut s, "paint.setFill", json!({"color": "#e0c020"}));
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [shadowed]}));
let mut png = vec![];
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
run(&mut s, "file.place", json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 120]}));
s
}
fn export(s: &mut Session, p: Value) -> (Vec<u8>, Vec<String>) {
let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
(bytes, v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect())
}
/// The transparency `pdf` has, in its dictionaries.
fn transparency(pdf: &[u8]) -> Vec<&'static str> {
let text = String::from_utf8_lossy(pdf);
let mut found = vec![];
for (what, there) in [
("soft masks", text.contains("/SMask") && !text.contains("/SMask/None")),
("blend modes", text.contains("/BM") && !text.contains("/BM/Normal")),
("transparency groups", text.contains("/S/Transparency")),
("opacity", text.contains("/ca 0.") || text.contains("/CA 0.")),
] {
if there {
found.push(what);
}
}
found
}
fn render(d: &vectorcraft_doc::Document) -> Rendered {
Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 160.0), 1.0, true)
}
/// Mean difference of the colour channels of two renders, as a share of full scale.
fn mean_diff(a: &Rendered, b: &Rendered) -> f64 {
let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum();
sum as f64 / a.pixels.len() as f64 / 255.0
}
#[test]
fn pdf_1_3_flattens_all_transparency_and_looks_the_same() {
let mut s = session();
let before = s.doc().unwrap().doc.clone();
// The same document at PDF 1.4 has transparency.
let (pdf, _) = export(&mut s, json!({"compatibility": "1.4", "compression": {"compressText": false}, "preserveEditing": false}));
assert!(transparency(&pdf).len() >= 3, "{:?}", transparency(&pdf));
let (pdf, warnings) = export(&mut s, json!({"compatibility": "1.3", "compression": {"compressText": false}, "preserveEditing": false}));
assert!(pdf.starts_with(b"%PDF-1.3"));
assert!(String::from_utf8_lossy(&pdf).contains("<pdf:PDFVersion>1.3<"), "the metadata says 1.3 too");
assert_eq!(transparency(&pdf), Vec::<&str>::new());
assert!(warnings.iter().any(|w| w.contains("flattened") && w.contains("PDF 1.3")), "{warnings:?}");
// It reads back looking like the canvas.
let canvas = render(&before);
let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&pdf).unwrap()));
assert!(diff < 0.02, "read back {:.2}% off", diff * 100.0);
// The document itself is untouched.
assert_eq!(s.doc().unwrap().doc, before);
// The writer refuses a document that isn't flat; a flat one with isolated page blending gets
// no page group.
let opts = vectorcraft_pdf::PdfOptions { settings: serde_json::from_value(json!({"compatibility": "1.3"})).unwrap(), ..Default::default() };
let e = vectorcraft_pdf::export(&before, &opts).unwrap_err();
assert!(matches!(&e, vectorcraft_pdf::PdfError::Unsupported(m) if m.contains("PDF 1.3")), "{e}");
let mut flat = vectorcraft_doc::Document::new(100.0, 100.0);
flat.page_isolate = true;
let l = flat.default_layer().unwrap();
let id = flat.alloc_id();
let rect = vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0));
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
flat.insert(Some(l), 0, vectorcraft_doc::Node::path(id, rect, vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0)))
.unwrap();
assert!(transparency(&vectorcraft_pdf::export(&flat, &opts).unwrap()).is_empty());
}
#[test]
fn the_flattener_preset_and_options_apply() {
let mut s = session();
let images = |pdf: &[u8]| String::from_utf8_lossy(pdf).matches("/Subtype/Image").count();
let p = |extra: Value| {
let mut p = json!({"compatibility": "1.3", "preserveEditing": false});
super::pdf::merge(&mut p, &extra);
p
};
let (high, _) = export(&mut s, p(json!({})));
let (default, _) = export(&mut s, p(json!({"flattenerPreset": "High Resolution"})));
assert_eq!(high.len(), default.len(), "the default is High Resolution");
let (low, _) = export(&mut s, p(json!({"flattenerPreset": "low"})));
assert!(low.len() < high.len(), "lower resolution rasters");
// All raster: one image a flattened group at most, and still flat.
let (raster, _) = export(&mut s, p(json!({"flattener": {"balance": 0}})));
assert!(images(&raster) >= 1 && transparency(&raster).is_empty());
// A saved preset by name, through every PDF path; an unknown one is refused.
s.execute("flattener.presets.save", &json!({"name": "All Raster", "preset": "high", "balance": 0})).unwrap();
let (saved, _) = export(&mut s, p(json!({"flattenerPreset": "All Raster"})));
assert_eq!(saved.len(), raster.len());
let v = s
.execute("document.export", &json!({"format": "pdf", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false}))
.unwrap();
assert_eq!(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap().len(), raster.len());
s.execute("pdf.preset.save", &json!({"name": "Old Press", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false}))
.unwrap();
assert_eq!(export(&mut s, json!({"preset": "Old Press"})).0.len(), raster.len(), "a PDF preset keeps its flattener preset");
assert!(s.execute("document.exportPdf", &p(json!({"flattenerPreset": "Nope"}))).is_err());
// Other versions keep their transparency, whatever the preset.
let (kept, warnings) = export(&mut s, json!({"flattenerPreset": "low", "compression": {"compressText": false}}));
assert!(!transparency(&kept).is_empty() && !warnings.iter().any(|w| w.contains("flattened")));
}
#[test]
fn pdf_1_3_files_are_encrypted_with_40_bit_rc4_and_can_be_linearised() {
let mut s = session();
let (pdf, warnings) = export(
&mut s,
json!({"compatibility": "1.3", "fastWebView": true, "thumbnails": true, "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": false}}),
);
assert!(pdf.starts_with(b"%PDF-1.3") && warnings.iter().all(|w| !w.contains("metadata")), "{warnings:?}");
let text = String::from_utf8_lossy(&pdf);
assert!(text.contains("/V 1/R 2/Length 40") && text.contains("/Linearized 1") && text.contains("/Thumb "));
for password in ["open", "owner"] {
s.execute("document.close", &json!({"force": true})).ok();
s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": password})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "{password}");
}
assert!(
s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": "nope"})).is_err()
);
}
@@ -149,7 +149,9 @@ fn load_options_read_pages_crop_and_password() {
#[test]
fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 moveto 100 100 lineto stroke\nshowpage\n%%EOF\n";
// PostScript opens through the EPS reader now (tests_epsimport.rs): a program it can't read,
// with no preview to fall back on, still says so.
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 moveto 100 100 frobnicate stroke\nshowpage\n%%EOF\n";
let mut s = Session::new();
for name in ["legacy.ai", "art.eps", "named.pdf", "template.ait"] {
let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string();
@@ -0,0 +1,61 @@
//! `document.exportPdf {createLayers}` writes PDF layers that reopen as layers, and
//! `advanced.overprint` keeps overprinting in the file or drops it.
use serde_json::{Value, json};
use super::*;
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
fn warnings(v: &Value) -> Vec<String> {
v["warnings"].as_array().expect("warnings").iter().map(|w| w.as_str().unwrap().to_string()).collect()
}
/// A document with a rectangle on "Layer 1" and one on a hidden, non-printing layer "Notes".
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let notes = s.execute("layer.new", &json!({"name": "Notes"})).unwrap()["id"].as_u64().unwrap();
s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("layer.setProps", &json!({"id": notes, "visible": false, "printable": false})).unwrap();
s
}
#[test]
fn export_pdf_create_layers_writes_layers_that_reopen_as_layers() {
let mut s = session();
let v = s.execute("document.exportPdf", &json!({"createLayers": true, "preserveEditing": false})).unwrap();
assert!(warnings(&v).is_empty(), "{:?}", warnings(&v));
let back = vectorcraft_pdf::import(&b64(&v)).unwrap();
let layers: Vec<(String, bool, bool)> = back
.layers
.iter()
.map(|l| (l.name.clone().unwrap_or_default(), l.visible, matches!(l.kind, vectorcraft_doc::NodeKind::Layer { printable: true, .. })))
.collect();
assert_eq!(layers, [("Layer 1".to_string(), true, true), ("Notes".to_string(), false, false)]);
// The default preset carries the editing data too.
let v = s.execute("document.exportPdf", &json!({"createLayers": true})).unwrap();
assert!(warnings(&v).is_empty(), "{:?}", warnings(&v));
assert!(vectorcraft_pdf::editing(&b64(&v)).is_some_and(|e| e.intact));
// At PDF 1.4 there are no PDF layers.
let v = s.execute("document.exportPdf", &json!({"createLayers": true, "compatibility": "1.4"})).unwrap();
assert!(warnings(&v).iter().any(|w| w.contains("PDF 1.5")), "{:?}", warnings(&v));
}
#[test]
fn export_pdf_overprint_is_preserved_or_discarded() {
let mut s = session();
s.execute("select.all", &json!({})).unwrap();
// The rectangles' strokes are black (their white fills would knock out: discardWhiteOverprint).
s.execute("object.setOverprint", &json!({"stroke": true})).unwrap();
let overprints = |s: &mut Session, p: Value| {
let v = s.execute("document.exportPdf", &p).unwrap();
assert!(warnings(&v).iter().all(|w| !w.contains("overprint")), "{:?}", warnings(&v));
String::from_utf8_lossy(&b64(&v)).matches("/OP true/op true/OPM 1").count()
};
assert_eq!(overprints(&mut s, json!({"compression": {"compressText": false}})), 1);
assert_eq!(overprints(&mut s, json!({"advanced": {"overprint": "discard"}})), 0);
}
@@ -0,0 +1,40 @@
//! `document.exportPdf` output and advanced options reach the writer: colours converted and tagged
//! with profiles, the output intent written, and type as real text.
use serde_json::{Value, json};
use super::*;
fn pdf(s: &mut Session, p: Value) -> (String, Value) {
let v = s.execute("document.exportPdf", &p).unwrap();
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
(String::from_utf8_lossy(&bytes).into_owned(), v["warnings"].clone())
}
#[test]
fn export_pdf_converts_colours_writes_the_output_intent_and_real_text() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
s.execute("paint.setFill", &json!({"color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}})).unwrap();
s.execute("text.create", &json!({"x": 80, "y": 60, "text": "Press ready"})).unwrap();
let generic = vectorcraft_color::cms::GENERIC_CMYK;
let p = json!({
"preset": "VectorCraft Default",
"compression": {"compressText": false},
"output": {"conversion": "destination", "destination": generic, "profiles": "destination", "outputIntent": generic, "outputConditionId": "Generic press", "trapped": true},
"advanced": {"outlineText": false},
});
let (text, warnings) = pdf(&mut s, p.clone());
assert_eq!(warnings, json!([]), "everything asked for is applied");
assert!(text.contains("/ICCBased") && text.contains("/N 4"), "CMYK colours tagged with the destination");
assert!(text.contains("/S/GTS_PDFX") && text.contains("/Trapped/True"));
assert!(text.contains("/ToUnicode") && (text.contains("/FontFile2") || text.contains("/FontFile3")), "real text");
// The Summary has nothing to report either.
let mut q = p;
q["includeDocument"] = json!(true);
assert_eq!(s.execute("document.pdfSettings", &q).unwrap()["warnings"], json!([]));
// Converting to a profile that isn't there is a bad parameter.
let bad = s.execute("document.exportPdf", &json!({"output": {"conversion": "destination", "destination": "Missing"}}));
assert!(matches!(bad, Err(crate::EngineError::BadParams { .. })), "{bad:?}");
}
@@ -0,0 +1,58 @@
//! Opening password-protected PDFs: the open password or the permissions password unlocks them.
use serde_json::{Value, json};
use vectorcraft_testkit::pdf::{PdfPage, pdf};
use super::*;
#[test]
fn the_permissions_password_opens_a_protected_pdf_too() {
let mut s = Session::new();
// The test file's permissions (owner) password is its open password followed by `-owner`.
let locked = vectorcraft_format::base64_encode(&pdf(&[PdfPage::new(80.0, 40.0, "0 g 0 0 10 10 re f")], Some("pw")));
let info = |s: &mut Session, pw: &str| s.execute("document.pdfInfo", &json!({"dataBase64": locked, "password": pw})).unwrap();
assert_eq!(info(&mut s, "pw-owner")["pages"], 1);
assert_eq!(info(&mut s, "pw-owners")["wrongPassword"], true);
let open = |s: &mut Session, pw: &str| s.execute("document.open", &json!({"name": "locked.pdf", "dataBase64": locked, "password": pw}));
let e = open(&mut s, "nope").unwrap_err().to_string();
assert!(e.contains("password is wrong"), "{e}");
open(&mut s, "pw-owner").unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 80.0);
}
#[test]
fn export_with_passwords_writes_a_pdf_that_only_its_passwords_open() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
for compatibility in ["1.4", "1.5", "1.6", "1.7", "2.0"] {
let p = json!({"compatibility": compatibility, "security": {"openPassword": "open", "permissionsPassword": "owner", "printing": "none"}});
let out = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
let data = out["dataBase64"].as_str().unwrap().to_string();
let bytes = vectorcraft_format::base64_decode(&data).unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt"), "{compatibility}: not encrypted");
let info = |s: &mut Session, p: Value| s.execute("document.pdfInfo", &p).unwrap();
assert_eq!(info(&mut s, json!({"dataBase64": data}))["needsPassword"], true, "{compatibility}");
assert_eq!(info(&mut s, json!({"dataBase64": data, "password": "nope"}))["wrongPassword"], true, "{compatibility}");
for pw in ["open", "owner"] {
assert_eq!(info(&mut s, json!({"dataBase64": data, "password": pw}))["pages"], 1, "{compatibility} {pw}");
}
// The open password restores the editable document (Preserve Editing is on by default).
let mut r = Session::new();
r.execute("document.open", &json!({"name": "locked.pdf", "dataBase64": data, "password": "open"})).unwrap();
assert!(r.doc().unwrap().doc.node_count() >= 2, "{compatibility}: the art came back");
}
}
#[test]
fn permissions_without_a_password_warn_and_a_password_applies_them() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
let warns = |v: &Value| v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("permissions need a password"));
let open = s.execute("document.exportPdf", &json!({"security": {"printing": "none"}})).unwrap();
assert!(warns(&open), "{open}");
let locked = s.execute("document.exportPdf", &json!({"security": {"permissionsPassword": "owner", "printing": "none"}})).unwrap();
assert!(!warns(&locked), "{locked}");
let bytes = vectorcraft_format::base64_decode(locked["dataBase64"].as_str().unwrap()).unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt"));
}
@@ -0,0 +1,98 @@
//! `document.exportPdf {thumbnails, fastWebView}`: each page's thumbnail is the page drawn small
//! (its bleed and marks too, without the layers the page leaves out), and the file is linearised.
use std::io::Read;
use serde_json::{Value, json};
use super::*;
fn export(s: &mut Session, p: Value) -> (Vec<u8>, Vec<Value>) {
let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
(bytes, v["warnings"].as_array().unwrap().clone())
}
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from)
}
/// The page thumbnails of `pdf`, in file order: (width, height, RGB pixels).
fn thumbnails(pdf: &[u8]) -> Vec<(u32, u32, Vec<u8>)> {
let mut out = vec![];
let mut from = 0;
while let Some(at) = find(pdf, b"<</Width ", from) {
from = at + 1;
let head = String::from_utf8_lossy(&pdf[at..find(pdf, b">>", at).unwrap()]).into_owned();
if !head.contains("/ColorSpace/DeviceRGB/BitsPerComponent 8/Filter/FlateDecode/Length ") {
continue;
}
let num = |key: &str| -> usize {
let rest = &head[head.find(key).unwrap() + key.len()..];
rest[..rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len())].parse().unwrap()
};
let data = find(pdf, b"stream\n", at).unwrap() + 7;
let mut rgb = vec![];
flate2::read::ZlibDecoder::new(&pdf[data..data + num("/Length ")]).read_to_end(&mut rgb).unwrap();
out.push((num("/Width ") as u32, num("/Height ") as u32, rgb));
}
out
}
#[test]
fn thumbnails_show_the_pages() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
let second = s.doc().unwrap().doc.artboards[1].rect;
// A red first page, a green second one.
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": second.x0, "y": second.y0, "width": 200, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
// A non-printing layer covering the first page in blue.
let notes = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
s.execute("layer.setProps", &json!({"id": notes, "printable": false})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
let (pdf, warnings) = export(&mut s, json!({"thumbnails": true}));
assert_eq!(warnings, Vec::<Value>::new());
let thumbs = thumbnails(&pdf);
assert_eq!(thumbs.len(), 2, "one a page");
for ((w, h, rgb), colour) in thumbs.iter().zip([[255, 0, 0], [0, 255, 0]]) {
assert_eq!((*w, *h), (vectorcraft_pdf::THUMBNAIL_SIZE, 53), "the page's shape, 106 px long");
// The middle of the left half: the page's colour (the non-printing layer is left out).
let at = ((*h / 2 * w + w / 4) * 3) as usize;
assert_eq!(rgb[at..at + 3], colour);
}
// Non-printing layers included: the thumbnail shows them too.
let (pdf, _) = export(&mut s, json!({"thumbnails": true, "includeNonPrinting": true}));
let (w, h, rgb) = &thumbnails(&pdf)[0];
let at = ((*h / 2 * w + w / 4) * 3) as usize;
assert_eq!(rgb[at..at + 3], [0, 0, 255]);
// With the bleed the page grows, and so does its thumbnail.
let (pdf, _) = export(&mut s, json!({"thumbnails": true, "bleed": {"top": 50, "bottom": 50, "left": 0, "right": 0}}));
assert_eq!(thumbnails(&pdf).iter().map(|t| (t.0, t.1)).collect::<Vec<_>>(), [(106, 106), (106, 106)]);
// Off: none.
assert!(thumbnails(&export(&mut s, json!({})).0).is_empty());
}
#[test]
fn fast_web_view_through_every_pdf_path() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 3})).unwrap();
s.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
let linearised = |pdf: &[u8]| String::from_utf8_lossy(&pdf[..200.min(pdf.len())]).contains("/Linearized 1");
let (pdf, warnings) = export(&mut s, json!({"fastWebView": true, "thumbnails": true}));
assert!(linearised(&pdf) && warnings.is_empty(), "{warnings:?}");
// Reopened: the pages, and the document the editing data carries.
s.execute("document.close", &json!({"force": true})).ok();
let v = s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "web.pdf"})).unwrap();
assert!(v.is_object());
assert_eq!(s.doc().unwrap().doc.artboards.len(), 3);
assert!(s.doc().unwrap().doc.layers[0].children().unwrap().iter().any(|n| n.path_data().is_some()), "the ellipse is back");
// document.export takes the same options; without them the file isn't linearised.
let v = s.execute("document.export", &json!({"format": "pdf", "fastWebView": true, "range": "2"})).unwrap();
assert!(linearised(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()));
assert!(!linearised(&export(&mut s, json!({})).0));
}
@@ -0,0 +1,91 @@
//! `document.exportPdf {standard}` with the PDF/X standards: choosing one sets its version and
//! turns off what it forbids, PDF/X-1a and PDF/X-3 flatten transparency (images with see-through
//! pixels too) before writing, PDF/X-4 keeps it, and passwords are refused.
use serde_json::{Value, json};
use super::*;
/// A document with a CMYK rectangle under a half-opaque RGB one, and a PNG with see-through
/// pixels over both.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap();
s.execute("paint.setFill", &json!({"color": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 40, "y": 30, "width": 60, "height": 60})).unwrap();
s.execute("paint.setFill", &json!({"color": {"model": "rgb", "r": 1.0, "g": 0.2, "b": 0.0}})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
let mut png = vec![];
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
s.execute("file.place", &json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 50]})).unwrap();
s
}
/// `document.exportPdf` with `p` (content streams readable) → (the file's text, warnings).
fn pdf(s: &mut Session, mut p: Value) -> (String, Vec<String>) {
p["compression"] = json!({"compressText": false});
let v = s.execute("document.exportPdf", &p).unwrap();
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let warnings = v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect();
(String::from_utf8_lossy(&bytes).into_owned(), warnings)
}
#[test]
fn pdfx_1a_and_x3_flatten_transparency_and_x4_keeps_it() {
let mut s = session();
let before = s.doc().unwrap().doc.clone();
for (standard, header) in [("pdfX1a", "%PDF-1.3"), ("pdfX3", "%PDF-1.3")] {
let (text, warnings) = pdf(&mut s, json!({"standard": standard}));
assert!(text.starts_with(header), "{standard}");
assert!(warnings.iter().any(|w| w.contains("flattened")), "{standard}: {warnings:?}");
for no in ["/SMask", "/S/Transparency", "/ca 0.5"] {
assert!(!text.contains(no), "{standard}: no {no}");
}
assert!(text.contains("/S/GTS_PDFX") && text.contains("/GTS_PDFXVersion"), "{standard}");
}
// PDF/X-1a: CMYK only, the flattened colours too.
let (text, _) = pdf(&mut s, json!({"standard": "pdfX1a"}));
assert!(!text.contains("/DeviceRGB") && !text.contains("/ICCBased"));
assert!(!text.split_whitespace().any(|t| t == "rg"), "no RGB fills");
// The document itself is untouched.
assert_eq!(s.doc().unwrap().doc, before);
// PDF/X-4 keeps transparency (and the image's soft mask).
let (text, warnings) = pdf(&mut s, json!({"preset": "PDF/X-4:2010"}));
assert!(text.starts_with("%PDF-1.6"));
assert!(text.contains("/SMask") && !warnings.iter().any(|w| w.contains("flattened")), "{warnings:?}");
assert!(text.contains("<pdfxid:GTS_PDFXVersion"));
}
#[test]
fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
let mut s = session();
let settings = |s: &mut Session, p: Value| s.execute("document.pdfSettings", &p).unwrap()["settings"].clone();
// The default preset is PDF 1.7 and keeps editing data.
let v = settings(&mut s, json!({"standard": "pdfX4"}));
assert_eq!((&v["compatibility"], &v["preserveEditing"]), (&json!("1.6"), &json!(false)));
let v = settings(&mut s, json!({"standard": "pdfX1a", "createLayers": null, "preset": "Press Quality"}));
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.3"), &json!(false)));
let v = settings(&mut s, json!({"standard": "pdfX4", "compatibility": "1.5", "createLayers": true}));
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.5"), &json!(true)));
// What is asked for explicitly is checked, not changed.
for p in [
json!({"standard": "pdfX4", "compatibility": "1.7"}),
json!({"standard": "pdfX3", "compatibility": "1.6"}),
json!({"standard": "pdfX1a", "createLayers": true}),
json!({"standard": "pdfX3", "preserveEditing": true}),
json!({"standard": "pdfX4", "security": {"openPassword": "x"}}),
json!({"standard": "pdfX1a", "security": {"permissionsPassword": "x"}}),
json!({"standard": "pdfX1a", "output": {"outputIntent": vectorcraft_color::cms::SRGB}}),
json!({"standard": "pdfX4", "output": {"outputIntent": "No Such Press"}}),
] {
let e = s.execute("document.exportPdf", &p).unwrap_err();
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}");
}
// The built-in PDF/X presets export as they are.
for preset in ["PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] {
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
}
}
+95
View File
@@ -0,0 +1,95 @@
//! PSD export through `document.export`: the options reach the file (layers, groups, names, colour
//! model), impossible ones are refused, and `document.formats` lists the format.
use serde_json::{Value, json};
use super::*;
/// A 60 × 40 pt document: a rectangle on Layer 1, and the text "Hello" on "Words".
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 60, "height": 40})).unwrap();
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
s.execute("layer.new", &json!({"name": "Words"})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 30, "text": "Hello"})).unwrap();
s
}
fn export(s: &mut Session, p: Value) -> Vec<u8> {
let r = s.execute("document.export", &merge(json!({"format": "psd"}), p)).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// The colour mode, the layer count and the layers' names, bottom first.
fn layers(f: &[u8]) -> (u16, i16, Vec<String>) {
assert_eq!(&f[..4], b"8BPS");
let be16 = |at: usize| u16::from_be_bytes([f[at], f[at + 1]]);
let be32 = |at: usize| u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) as usize;
let mut at = 26;
at += 4 + be32(at);
at += 4 + be32(at);
let count = if be32(at) == 0 { 0 } else { be16(at + 8) as i16 };
// Every record's Unicode name block.
let names = f
.windows(8)
.enumerate()
.filter(|(_, w)| w == b"8BIMluni")
.map(|(i, _)| {
let n = be32(i + 12);
String::from_utf16(&(0..n).map(|k| be16(i + 16 + k * 2)).collect::<Vec<_>>()).unwrap()
})
.collect();
(be16(24), count, names)
}
#[test]
fn psd_writes_layers_groups_and_text_named_after_it() {
let mut s = session();
let (mode, count, names) = layers(&export(&mut s, json!({})));
assert_eq!((mode, count, names), (3, -2, vec!["Layer 1".to_string(), "Words".to_string()]), "a pixel layer per top-level layer");
let (_, count, names) = layers(&export(&mut s, json!({"maxEditability": true})));
assert_eq!(count, -6);
assert_eq!(names, ["</Layer group>", "<Rectangle>", "Layer 1", "</Layer group>", "Hello", "Words"], "groups, and text named after its text");
let (mode, count, names) = layers(&export(&mut s, json!({"layers": false, "colorModel": "cmyk"})));
assert_eq!((mode, count, names.len()), (4, 0, 0), "flat CMYK");
assert_eq!(layers(&export(&mut s, json!({"colorModel": "gray"}))).0, 1);
// A white background is a Background layer under the others.
let (_, count, names) = layers(&export(&mut s, json!({"background": "white"})));
assert_eq!((count, names[0].as_str()), (3, "Background"));
// Hidden layers: left out, unless asked for.
let words = s.doc().unwrap().doc.layers[1].id;
s.execute("layer.setProps", &json!({"id": words.0, "visible": false})).unwrap();
assert_eq!(layers(&export(&mut s, json!({}))).2, ["Layer 1"]);
assert_eq!(layers(&export(&mut s, json!({"hiddenLayers": true}))).2, ["Layer 1", "Words"]);
assert!(s.execute("document.export", &json!({"format": "psd", "colorModel": "lab"})).is_err());
}
#[test]
fn psd_is_listed_with_its_options_and_writes_one_file_per_artboard() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 30, "height": 20, "artboards": 2})).unwrap();
let r = s.execute("document.formats", &json!({})).unwrap();
let psd = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "psd").unwrap().clone();
assert_eq!(
(psd["label"].as_str(), psd["write"].as_bool(), psd["read"].as_bool(), psd["raster"].as_bool()),
(Some("PSD"), Some(true), Some(false), Some(true))
);
for k in ["colorModel", "layers", "maxEditability", "hiddenLayers", "embedIcc", "ppi", "antiAlias", "background"] {
assert!(psd["options"].get(k).is_some(), "psd takes {k}");
}
let r = s.execute("document.export", &json!({"format": "psd", "useArtboards": true})).unwrap();
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(files, ["Untitled-1-Artboard-1.psd", "Untitled-1-Artboard-2.psd"]);
// One artboard, as bytes.
let r = s.execute("document.serialize", &json!({"format": "psd", "artboard": 1})).unwrap();
assert_eq!(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()[..4], b"8BPS");
// 32000 pixels a side: within raster exports' limits, beyond the format's.
s.execute("file.new", &json!({"width": 500, "height": 10})).unwrap();
let err = s.execute("document.export", &json!({"format": "psd", "scale": 64})).unwrap_err().to_string();
assert!(err.contains("too large for PSD"), "{err}");
}
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff. Show more