Compare commits

...
167 Commits
Author SHA1 Message Date
BFlat 035f1cb2f6 Merge pull request #2951 from Hamza0101/inch-ruler-binary-ticks
Rulers: an inch is divided in half, not in ten
2026-10-20 00:02:15 +07:00
BFlat e52b07105d Merge pull request #3107 from FractalEncrypt/fix/status-bar-layout
Keep status-bar messages and readouts stable
2026-10-20 00:01:57 +07:00
Hamza 00f1231334 Tab stops: Shift snaps to the ruler's binary inch ticks
`shift_drag_snaps_a_stop_to_the_nearest_tick` landed while this branch was out
and asserts the tick an inch ruler used to carry: "the ticks are a tenth of an
inch (7.2 pt) apart", snapping 100 pt to 100.8. That tenth is what this change
removes -- an inch ruler divides the inch in half, not in ten.

The nearest tick to 100 pt is now 99 pt: 1 3/8 in, an eighth of an inch from
1 1/4. The test says so, and also asserts the result is a binary fraction of an
inch rather than a particular number, so it holds if the ladder is retuned.
2026-10-19 23:47:04 +07:00
Hamza c58c642f07 Rulers: the left ruler leaves out a label that would run into the one above
A label on the left ruler is spelled one character per line, so a long one is
tall: "-0.0625" is seven characters and reaches 60.5 pt down the ruler, while a
sixteenth-inch step leaves 54 pt at zoom 12. Binary steps made such labels
reachable -- the decimal ladder's "-0.1" was four characters in an 86 pt gap --
so the ruler could draw one label over another.

A label that would start inside the one above it is left out. Every tick still
draws, as do the labels that fit, and `vertical_label_height` gives the reach
of a label so the decision is one comparison.
2026-10-19 23:47:04 +07:00
Hamza 39b59b5730 Rulers: stop labelling an inch ruler at a sixteenth
Labelling to a hundred and twenty-eighth left the left ruler unreadable: it
spells a label one character per line, 8.5 pt apart, and at zoom 50 the step is
1/64 with labels 56.25 pt apart while "0.015625" needs 61.5 pt. At zoom 100 it
is "0.0078125" in the same space.

The label ladder stops at a sixteenth, as the report says Illustrator's does,
so zooming in past that leaves the labels where they are and only the ticks
between them grow denser -- down to a hundred and twenty-eighth, which is the
32nds, 64ths and 128ths the report expects to appear unlabelled. Every label is
at most the six characters of "0.0625", which fits the 50 pt the ladder leaves.
2026-10-19 23:47:04 +07:00
Hamza 2c6a2ad082 Rulers: an inch is divided in half, not in ten
Zooming in on an inch ruler stepped through 0.2, 0.1 and 0.05 of an inch:
`Unit::ruler_step` picked from one decimal ladder for every unit, and the
rulers divided whatever it returned by ten. A tenth of an inch is not a mark an
inch ruler carries (#2827).

Below an inch the steps are binary fractions now, 1/2 down to 1/128, and
`Unit::ruler_minor` gives the unlabelled ticks between two labels: an eighth of
the step on an inch ruler, so every tick is a binary fraction of an inch and
they stay about as dense as the tenths elsewhere. An inch and wider keeps the
shared ladder, and the metric units, points, pixels and picas keep their
tenths. Ruler-tick snapping follows the ticks that are drawn, which moves an
inch document's Shift-drag of a guide onto the same grid.

`ruler_label` took its decimals from `(-step.log10()).ceil()`, which writes
1/16 as "0.06" and 1/8 as "0.13" -- wrong values, not just rounded ones. It
uses the fewest decimals that write the step exactly, and drops trailing zeros
as `Unit::number` does, so a sixteenth-inch ruler labels the inch `1` rather
than spelling `1.0000` down six lines of the vertical ruler.
2026-10-19 23:47:03 +07:00
FractalEncrypt b09e23f4a1 M14: cache status-readout sample widths by display settings 2026-10-19 23:47:01 +07:00
FractalEncrypt 1e914c8d82 M14: document the status-bar readouts, helpers and regressions 2026-10-19 23:46:59 +07:00
FractalEncrypt 45d9a409d4 M14: keep status messages and readouts stable 2026-10-19 23:46:59 +07:00
BFlat 9d4675e7d4 Merge pull request #2962 from MiQ71/fix/issue-1299-dxf-circle-arc
DXF: export circular paths as native CIRCLE and ARC entities
2026-10-19 23:43:53 +07:00
BFlat d5dfbf4bc3 Merge pull request #3106 from FractalEncrypt/fix/canvas-reprojection-resize
Preserve cached canvas scale during viewport resizing
2026-10-19 23:43:36 +07:00
Mirko Kerezovic af48dda322 M4: address DXF circle export review (#1299) 2026-10-19 23:34:20 +07:00
Mirko Kerezovic b87cb157ed M4: export DXF circles and arcs as native entities (#1299) 2026-10-19 23:34:19 +07:00
bflatastic d2b927b57b Docs: the canvas-scale gap entry reads as the others do (#3106) 2026-10-19 23:34:18 +07:00
FractalEncrypt 04750ece1b M11: bound cached canvas dimensions to the renderer limit 2026-10-19 23:34:17 +07:00
FractalEncrypt c08955320c M11: document the canvas show entry point 2026-10-19 23:34:16 +07:00
FractalEncrypt 742a5c1af2 M11: preserve cached canvas scale during viewport changes 2026-10-19 23:34:16 +07:00
BFlat 1f80c28662 Merge pull request #3131 from storytold/fix/batch-zd-followups
Batch follow-ups: #3004 test prints, #3095 catalog rows, #3042 doc banners
2026-10-19 23:17:58 +07:00
bflatastic 6fed871f88 Docs: the live-text PDF Place notes go in the docs' own sections, without update banners (#3042) 2026-10-19 23:14:20 +07:00
bflatastic 3033fb9985 i18n: Move Anchor Point, the label of path.moveAnchor, in every catalog that has its sibling labels (#3095) 2026-10-19 23:14:09 +07:00
bflatastic 7e1095f9b1 Tests: the Type arrow tests drop their debug prints (#3004) 2026-10-19 23:14:09 +07:00
BFlat f378c5c836 Merge pull request #3042 from KnottyDyes/squad/place-pdf-keeps-text
PDF: placed and pasted pages keep live text
2026-10-19 23:13:58 +07:00
BFlat 55acd88d42 Merge pull request #3001 from ariymarkowitz/claude/changes
#2558: a drag over a wide blur draws it from the still frame's raster
2026-10-19 23:13:46 +07:00
BFlat 319221c087 Merge pull request #3102 from dotCooCoo/fix/effect-art-follows-fonts-and-color-settings
Effects: effect art redraws when fonts arrive or the color settings change
2026-10-19 23:13:31 +07:00
BFlat 9d43326ad7 Merge pull request #3095 from sungjea713/anchor-point-shift-moves-anchor
Anchor Point tool: Shift-drag on an anchor moves it, handles and all
2026-10-19 23:13:12 +07:00
BFlat 8baf5b1343 Merge pull request #3004 from sanyamsingh001/codex/r82-type-grapheme-boundaries
Keep Type arrows on grapheme boundaries in mixed-direction text
2026-10-19 23:12:59 +07:00
BFlat a60f8d7b9f Merge pull request #3120 from Janiz-zzz/fi-update-20261019
i18n(fi): translate 67 labels added since the Finnish review
2026-10-19 23:12:41 +07:00
BFlat 2c52bed0b5 Merge pull request #2961 from Hamza0101/encrypted-pdf-note
Exports: say that an encrypted PDF still differs, and test the clock fallback
2026-10-19 23:12:28 +07:00
BFlat de3dd27fda Merge pull request #2988 from 00xJS/fix/2675-pdf-invisible-text
PDF import: say when invisible text is left out (part of #2675)
2026-10-19 23:12:13 +07:00
BFlat 575bd0f924 Merge pull request #3006 from rotohun/fix/ai-hyphenation-tab-operators
eps: read Illustrator text with hyphenation and tab attributes
2026-10-19 23:11:54 +07:00
BFlat 1ef66c48c1 Merge pull request #2969 from dotCooCoo/fix/data-sets-file-dialogs-off-ui-thread
Variables: Import and Export Data Sets… show file dialogs on macOS and keep the window running
2026-10-19 23:11:36 +07:00
BFlat 35b5895183 Merge pull request #2964 from dotCooCoo/fix/pdfinfo-thumbnail-page
MCP: document.pdfInfo refuses a thumbnail that is not a page number
2026-10-19 23:11:22 +07:00
BFlat 0bd2a40128 Merge pull request #3074 from Silvertree2010/fix/3036-objects-as-guides-blocks-group-d
Stop locked guide objects from blocking marquee selection
2026-10-19 23:11:05 +07:00
BFlat 605597b09c Merge pull request #2968 from Silvertree2010/fix/h2813-deleting-an-anchor-from-a-closed
Clear dangling handles when deleting anchors from a closed path
2026-10-19 23:10:47 +07:00
BFlat 646563b033 Merge pull request #3115 from Silvertree2010/fix/h550-polygon-covers-rect-treats-a-sel
Reject self-intersecting polygons in polygon_covers_rect
2026-10-19 23:10:35 +07:00
BFlat 2861c113d6 Merge pull request #2821 from dotCooCoo/feat/linux-desktop-ai-eps-types
Linux: file managers offer VectorCraft for .ai, EPS and every other format Finder offers it for
2026-10-19 22:40:08 +07:00
KnottyDyes 48a0206adb PDF: placed and pasted pages keep live text
Honor the requested text mode in the single-page importer, as Open does, instead of forcing outlines for Place and PDF clipboard import.

Add synthetic regressions for bytes, unlinked paths, selected pages and explicit outlines. Update format parity and milestone evidence without changing scores.
2026-10-19 22:19:59 +07:00
Ari MarkowitzandClaude Opus 5.5 1fc3c23096 #2558: the coarser glow test also pans by fractions of a pixel
For review: after pans of (0.5, 0) and (37.3, -21.6), the Outer Glow
made coarser over its whole reach (scale 2 and 3) is still drawn from
the kept raster, placed at the nearest whole pixel as a full-size
raster kept whole is, and each frame equals a fresh renderer's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:57 +07:00
Ari MarkowitzandClaude Opus 5.5 7dc0c034d8 #2558: a coarser glow kept whole draws after a pan as made again
A test for review: an Outer Glow made coarser over its whole reach is
kept for any pan, since its grid starts at the reach's first whole
device pixel, which a whole-pixel pan moves with the art. After pans
that aren't multiples of its scale (2 and 3), the kept raster draws
exactly what a fresh renderer draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:57 +07:00
Ari MarkowitzandClaude Opus 5.5 be7fd07562 #2558: a drag over a wide blur draws it from the still frame's raster
An effect raster too large to keep whole (a wide blur, a shadow or glow,
or any blur zoomed in) is cropped to what can show in the view, or made
at a lower resolution and resampled to the view's pixels, and kept while
the view stays the same. Dragging other art over the object then draws
its effects from the still frame instead of previewing them at the
drag's lower resolution on every frame. An interaction's frames stay on
the render worker once it drew the frame shown, since only its renderer
holds those rasters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:57 +07:00
dotCooCoo 007f536c79 Effects: effect art redraws when fonts arrive or the color settings change
The renderer kept the art it made for an object's effects while the
object stayed the same. In the web build, type that a geometry effect
outlines, on its own, in a group or in a symbol's art, kept the
substitute font's outlines after its own font loaded. On the desktop,
Refresh Font List left them on a symbol instance whose art holds the
type. Objects with Revolve, Extrude & Bevel, Inflate or Rotate, and
objects and groups with a color adjustment, kept the colors they were
shaded or recolored with after the color settings changed. Live objects
already followed both.

The renderer now keeps that art with the inputs it keeps for the art
of live objects: the font generation when the art holds type, the art
of the symbols it shows, and the color settings. A temporary release
test timed the added key at 11 microseconds a frame for 400 type
objects with a drop shadow and 0.77 ms for 400 symbol instances of 100
paths each with a drop shadow, against warm frames of 5.3 and 72 ms.

Docs: docs/target-app-parity.md, docs/gaps.md.
2026-10-19 22:19:55 +07:00
sungjea kimandClaude Opus 5.5 515569470c Anchor Point tool: Shift-drag on an anchor moves it, handles and all
With the Anchor Point tool, a drag from an anchor pulls out smooth handles.
Shift held at the press now moves the anchor instead, its handles keeping
their offsets; Shift kept down during the drag keeps the move at 45° steps
(the constrain angle), released it frees it. A Shift-click that doesn't
move leaves the anchor as it was. A selected blend's spine points follow
the same rule (object.blend.spine.moveAnchor).

The move goes through a new engine command, path.moveAnchor
{id, subpath?, anchor, x, y}, since path.moveAnchors only acts on the
direct-selected anchors and the Anchor Point tool keeps no anchor selection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:53 +07:00
sanyamsingh001andCodex c07945b934 fix: keep wrapped Type fallback on grapheme boundaries
Co-authored-by: Codex <[email protected]>
2026-10-19 22:19:53 +07:00
sanyamsingh001andCodex de4c6b1a6d fix: keep Type arrows on grapheme boundaries
Co-authored-by: Codex <[email protected]>
2026-10-19 22:19:53 +07:00
Janiz-zzzandClaude Opus 5.5 ecaae7ee46 i18n(fi): translate 67 labels added since a466625c
Finnish rows for the labels the other catalogs gained since the Finnish
review merge: dimension tool modes and prompts, image trace blur modes,
the swatch library colour names (swatch context), batch action options
and two status messages. Workspace and perspective preset names stay in
English (MENU_KEEP_AS_IS), as in the other complete catalogs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:52 +07:00
Hamza 7bc7025a62 Exports: say that an encrypted PDF still differs, and test the fallback
Two review points on #2880.

The reproducibility claim needed qualifying. PDF encryption draws its AES
initialisation vectors and the file `/ID` from a random seed, which
`SOURCE_DATE_EPOCH` deliberately does not reach, so a PDF exported with a
password never repeats however the variable is set. The docs and the ROADMAP
line say so now.

The byte-identity test passes an explicit `created`, so it never exercised the
clock-or-variable fallback. `pinned_or` takes the environment and the clock as
arguments, and the new test shows the variable is preferred, that a blank,
negative or non-numeric value leaves it to the clock, and that a host with
neither has no date to record. The environment is injected rather than set: a
test that changes this process's environment is `unsafe` under edition 2024 and
would reach every other test running beside it, which is why `cursor_theme`
takes a lookup closure too.
2026-10-19 22:19:51 +07:00
00xJ0 8d123b19ca Test: check which text the PDF import keeps beside invisible text (#2675 review)
The visible line's text comes in as type and the invisible line's doesn't; as type or outlines,
all the art kept lies on the visible line's baseline, none on the invisible one's.
2026-10-19 22:19:50 +07:00
00xJ0 4aa4b0e70b PDF import: say when invisible text is left out (part of #2675)
Text in rendering mode 3 (invisible: a scanned page's searchable OCR layer) was dropped with no
warning, so a scanned PDF opened and saved again lost its searchable text without a hint. The
import now reports INVISIBLE_TEXT_NOTE when it leaves such text out, for type and outlines alike.

Keeping the text needs a kind of type that is kept but not drawn, and the PDF writer (krilla, in
craft-libs) to write it with `3 Tr`; its text API has fill, stroke and fill-and-stroke only.
2026-10-19 22:19:50 +07:00
Ronald Hunter 4614c08476 eps: read Illustrator text with hyphenation and tab attributes
Illustrator 6/8 EPS files whose PostScript can't be run (the Adobe_level2_AI5
procset is provided by the RIP, not embedded) are read through their private
layer data instead. That reader aborted the whole file when a text object
carried the hyphenation/tab-ruler operators `Th` (hyphen control), `XL`
(hyphenation language) and `Xb`/`XB` (a tab definition, with `Xe` descriptions
between them), reporting them as operators it doesn't know.

These carry no geometry, so drop them like the sibling type attributes the
reader already discards (§22 of the Illustrator File Format Specification)
rather than failing the import.
2026-10-19 22:19:49 +07:00
dotCooCoo 2ecc488111 Variables: Import and Export Data Sets… show file dialogs on macOS and keep the window running
The Variables panel menu called import_datasets and export_datasets
directly, so their file dialogs were asked for outside an entry and
shown in line. macOS shows no dialog in line, so the two items did
nothing there. On Linux the dialog held the window until it closed.

Both items now go through picks::button, as the Libraries panel
menu's Import Library… and Export Library… do. When the dialog
answers, the item runs again with the picked path. Errors still go
to the status bar.

variables_panel_data_sets_ask_off_the_ui_thread clicks both items
with dialogs shown as sheets, as on macOS, imports a CSV and exports the
data sets to the path the dialog answers.
2026-10-19 22:19:47 +07:00
dotCooCoo 6ed171c1b4 MCP: document.pdfInfo refuses a thumbnail that is not a page number
document.pdfInfo read thumbnail with as_u64, so a value such as -1,
1.5, "1" or true gave a reply without a thumbnail and no error. This
happened for a PDF and for a PostScript file.

A thumbnail that is present and not null must now be a whole number
from 1 to the page count. Any other value is the existing "thumbnail:
no page {n} (the PDF has {count})" error, or "(the file has 1)" for
PostScript, with the value as given. An absent or null thumbnail still
gives no thumbnail.
2026-10-19 22:19:44 +07:00
NetterSilverandClaude Haiku 5.5 3eed3be8e6 Stop locked guide objects from blocking marquee selection
Fixes #3036

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:19:42 +07:00
NetterSilverandClaude Haiku 5.5 85af04a83a Clear dangling handles when deleting anchors from a closed path
Found while testing crates/engine/src/cmd/path.rs:632 (without_anchors, closed-path branch uses keep instead of open_run)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:19:39 +07:00
NetterSilverandClaude Haiku 5.5 bdd5470dcf Reject self-intersecting polygons in polygon_covers_rect
Found while testing crates/geom/src/hit.rs:12-48

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-19 22:19:37 +07:00
dotCooCoo 157679c1a8 Linux: file managers offer VectorCraft for .ai, EPS and every other format Finder offers it for
Squashed from #2821 (its merges of main dropped).
2026-10-19 22:08:38 +07:00
BFlat 320bffb8ad Merge pull request #3066 from dotCooCoo/feat/objects-on-path
Objects on Path: attach objects to a path and spread them along it
2026-10-19 22:07:42 +07:00
BFlat 3d23fc5175 Merge pull request #3052 from dotCooCoo/feat/live-paint-selection-options
Live Paint: Live Paint Selection Tool Options set what a click selects and the highlight
2026-10-19 22:07:12 +07:00
BFlat 8c6085beca Merge pull request #3033 from dotCooCoo/feat/layers-locate-selection-option
Layers: with Locate Selected Objects on, the panel shows the row of art selected on the canvas
2026-10-19 22:06:38 +07:00
BFlat 6cb075b926 Merge pull request #3039 from dotCooCoo/feat/tabs-snap-to-unit
Tabs: Snap to Unit, or Shift held, puts a tab stop on the ruler's nearest tick
2026-10-19 22:06:23 +07:00
BFlat 0f27503f5b Merge pull request #3049 from dotCooCoo/fix/3d-strokes-outlined
3D and Materials: Extrude & Bevel and Inflate build a solid from each stroke's outline
2026-10-19 22:06:10 +07:00
BFlat 92ca32e55d Merge pull request #3048 from dotCooCoo/feat/data-set-applies-nothing-message
Variables: a data set with no value for any bound variable shows a status message
2026-10-19 22:05:53 +07:00
BFlat 37ee8bee3c Merge pull request #3040 from dotCooCoo/fix/hover-highlight-snap-to-grid
Smart Guides: object highlighting stays off with Snap to Grid or Pixel Preview
2026-10-19 22:05:36 +07:00
dotCooCoo 219253f163 Objects on Path: attach objects to a path and spread them along it
VectorCraft had no Objects on Path. A click with the Objects on Path
tool attaches the selected objects to the path clicked. The tool is in
Edit Toolbar's Modify section and the Blend tool's flyout, and Object ›
Objects on Path › Attach and the context menu with two or more objects
selected choose it. The objects and the path go into an Objects on
Path group with the path at its bottom.

- object.objectsOnPath.attach orders the objects column by column from
  left to right and top to bottom in each column. The first one sits
  on the path's first anchor, the last one sits on the end of an open
  path, and the rest are spread evenly by length; a closed path takes
  them all the way round. Each object's attachment point (Pivot, one
  of nine, default the center of its bounds without strokes) sits on
  the path, and the object is turned to the path there. An object that
  was rotated by an angle other than a multiple of 90° is first turned
  to the nearest multiple of 90°. Rotate tilts every object by that
  many degrees counter-clockwise. Symbol instances are measured by
  their art. Guides, compound paths, type, paths without length and
  graphs are refused with a reason.
- After every edit the objects go back on the path: editing the path
  moves them, an object moved on its own goes back, and objects moved
  into or out of the group in the Layers panel join the end of the
  path or leave it. Scaling, shearing or reflecting the whole group
  keeps each object's scale, shear or reflection, and the objects are
  spread evenly along the transformed path again. Undo takes the path
  and the objects back in one step, and a copied group stays live on
  its own path.
- object.objectsOnPath.options changes Pivot and Rotate, and
  object.objectsOnPath.expand leaves a plain group. Both act on the
  groups that `ids` names, or on the groups of the selection when
  `ids` is not given.
- The native file keeps the group live; SVG, PDF and the other exports
  write plain art. The tool highlights the path under the pointer, has
  a cursor entry, and Escape during a press cancels the click.

Part of #2545

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 21:51:32 +07:00
dotCooCoo e6c8ce1759 Live Paint: Live Paint Selection Tool Options set what a click selects and the highlight
Double-clicking the Live Paint Selection tool did nothing, and the tool
always selected faces and edges and highlighted them in red at 4 px.
It now opens Live Paint Selection Tool Options with Select Fills,
Select Strokes, Color and Width, the options the reference app's help
page on selecting objects lists.

- With Select Fills off, a click selects the edge nearest the pointer,
  as Alt-click does. With Select Strokes off, Alt-click selects the
  face under the pointer, as a click does. With both off, a click
  selects no face or edge.
- Color (a layer color or a custom color) and Width (1 to 20 px) set
  the highlight on the face or edge under the pointer.
- The defaults are the earlier behavior: both selections on, red
  (#ff2a2a) and 4 px. The options are tool options (tool.setOption
  {tool: "livePaintSelection"}): they last across tool switches and
  are saved with the preferences.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 21:51:31 +07:00
dotCooCoo e1761eb489 Layers: the panel options command describes when Locate Selected Objects opens nothing
The params text of ui.layersPanelOptions and the doc comment on
PanelOptions::locate_selected said that, with the option on, the panel
opens the rows around any object selected outside it and scrolls to its
row. When Show Layers Only, a search or the filter leaves the object
without a row, the panel opens no rows for it and scrolls as with the
option off. Both texts now describe that case, as docs/mcp.md does.
2026-10-19 21:51:29 +07:00
dotCooCoo 7f01abd687 Layers: Locate Selected Objects skips a selection recalled from the panel menu
With Locate Selected Objects on, choosing a saved selection in the panel
menu's Save Selection submenu opened the rows around the recalled object
and scrolled to it. A selection changed with the panel's own menu is not
located, and the submenu's recall now follows that rule as the other
panel menu items do.
2026-10-19 21:51:29 +07:00
dotCooCoo 729582dc78 Layers: Locate Selected Objects opens nothing for an object the panel does not list
With Locate Selected Objects on, an object selected on the canvas had the
layers and groups around it opened even when Show Layers Only, a search or
the filter left it without a row. The panel did not scroll to it, but the
rows stayed open, so they showed once the panel listed every row again.

The panel now opens the rows around the object only when the object has a
row once they are open. Otherwise it opens nothing and scrolls as with the
option off. The Layers panel entries in docs/mcp.md and
docs/target-app-parity.md describe this.
2026-10-19 21:51:29 +07:00
dotCooCoo 7c64ecd998 Layers: with Locate Selected Objects on, the panel shows the row of art selected on the canvas
By default the Layers panel opens the layers around a row and scrolls
the row into view when its highlighted rows change, for example after
a click in the panel, a new layer or Locate Object. When no row is
highlighted and a selection makes another layer or sublayer the
active one, the panel does the same for that layer's row. Selecting
art on the canvas or by a command highlights no row, so by default
the panel does not open the layer or sublayer that holds the art or
the groups around it, and does not scroll to the art's row.

Panel Options gets Locate Selected Objects, off by default and kept
with the UI state. With it on, when the last selected object changes
on the canvas or by a command, the panel opens the layers and groups
around it and scrolls to its row, as after Locate Object, but does not
highlight the row. When the selection also changes the active layer,
the panel scrolls to the object's row instead of the layer's row.
When Show Layers Only, a search or the filter leaves the object
without a row, the panel scrolls as it does with the option off. A change made with the panel's own
rows, buttons or menu is not located, and a change made during a drag
is located when the drag ends. Locate Object is unchanged.

On a layer of 5,000 groups of 10 paths with every row open, the first
panel frame after a selection change took a median of 1.6 ms with the
option on and took a median of 1.4 ms with it off, in a release build.

Docs: docs/mcp.md (locateSelected), docs/target-app-parity.md.
2026-10-19 21:51:29 +07:00
dotCooCoo 55f98095f3 Tabs: Snap to Unit, or Shift held, puts a tab stop on the ruler's nearest tick
A tab stop placed or dragged on the Tabs panel's ruler always landed
at a fractional position, and the ruler was numbered in inches with a
tick every 18 pt whatever the document's units. The reference app
snaps stops to the ruler units with Snap To Unit in the panel menu or
with Shift held during a drag, and numbers its tab ruler in the
document's units.

The ruler is now numbered in the General unit with the canvas rulers'
label step at the ruler's scale, and has a tick every tenth of a step.
The panel menu has Snap to Unit, checked while it is on. With it on,
or with Shift held, a stop placed by a click or dragged on the ruler
lands on the nearest tick, through Unit::snap_to_ruler_tick as a
Shift-dragged ruler guide does. A snapped click that lands on an
existing stop's tick selects that stop and records no undo step. The
stops still change through text.tabs.set. The reference app's page
names Shift for a drag only; Shift also snapping a click, and a
snapped click onto a stop's tick selecting that stop, are VectorCraft's
own rules.

Docs: docs/target-app-parity.md.
2026-10-19 21:51:27 +07:00
dotCooCoo ac1b380b40 3D and Materials: Extrude & Bevel and Inflate build a solid from each stroke's outline
Extrude & Bevel and Inflate built one solid from the path, painted with
the object's top fill or, without a fill, its top stroke. A filled and
stroked object lost its stroke, and a stroked open path was extruded as
the area it would enclose if it were closed.

Each visible stroke now builds a solid from its outline in its own
paint, beside the solid the top fill builds from the path, as Rotate
already turns each stroke's outline. When the fill and the strokes are
opaque with normal blending, the faces of all the solids are drawn in
one back-to-front order; otherwise each solid is drawn on its own, in
the order of the appearance. Under a light that is not white, a surface
drawn as one composite or with an opacity mask is also drawn on its
own. In that one order a flat cap turned toward the viewer is drawn
after every other face, untwisted solids included, so the stroke's
front cap covers the fill's walls inside it. The solids share one set
of bounds, and with Perspective one eye sees every inflated solid. A
stroke whose outline is past the 2,048-point limit is left out.

Inflate's cells cut by a hole now pass only the nearby contours to the
boolean. An inflated, outlined 72 pt "VectorCraft" takes about 8 ms
instead of 34 ms, and 66 ms with a 3 pt stroke.

Docs: docs/three-d.md (Fills and strokes), docs/target-app-parity.md.
2026-10-19 21:51:26 +07:00
dotCooCoo c3a6aa0a29 Variables: a data set with no value for any bound variable shows a status message
Choosing a data set in the Variables panel, stepping through data sets
with the panel's arrows, and running `dataset.select`, `dataset.next`
and `dataset.prev` applied nothing and showed no message when no object
was bound to a variable that the data set has a value for. This
happens, for example, after Import Data Sets… defines new variables
without binding them.

The app now shows "nothing in data set A was applied: no object is
bound to a variable it has a value for" in the status bar, and the three
commands add it to the reply as `warning`. A data set with a value for
a bound variable gives no warning even when every object bound to it is
locked; `skipped` counts those objects. What applying a data set does
is unchanged.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 21:51:26 +07:00
dotCooCoo 1d214956f9 Smart Guides: object highlighting stays off with Snap to Grid or Pixel Preview
The reference app's help says Smart Guides cannot be used while Snap to
Grid or Pixel Preview is on, even with the menu command selected. The
Selection tool's move already leaves Smart Guides out under Snap to
Grid, but the canvas still drew the Object Highlighting outline under
the pointer with either view on.

The hover outline now draws only with Smart Guides and Object
Highlighting on and with Snap to Grid and Pixel Preview off.
2026-10-19 21:51:25 +07:00
Dylan M. Taylor a466625c89 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <[email protected]>
2026-10-18 12:31:54 -06:00
BFlat 73f921fa0a Merge pull request #2947 from Hamza0101/reproducible-export-dates
Exports: SOURCE_DATE_EPOCH stands in for the clock, so output repeats
2026-10-19 00:52:49 +07:00
BFlat fb18533e12 Merge pull request #2677 from dotCooCoo/feat/rotate-view-dial
Rotate View: a dial shows the angle while a drag rotates the view
2026-10-19 00:52:33 +07:00
BFlat 56be31fe9b Merge pull request #2670 from dotCooCoo/fix/joiners-stay-with-their-run
Type: a zero width non-joiner is shaped in the face of the letters around it
2026-10-19 00:52:19 +07:00
BFlat a23d7a827f Merge pull request #2948 from dotCooCoo/fix/open-pages-naming-every-page
PDF/.ai import: a .ai saved without PDF compatibility opens when pages names every page
2026-10-19 00:52:03 +07:00
Hamza 1c80b23745 Exports: SOURCE_DATE_EPOCH stands in for the clock, so output repeats
PNG, PDF, EPS, legacy `.ai`, packaged zips and saved `.vectorcraft` files
record the wall-clock time they were made in, so the same commands run twice
gave different bytes although the pixels matched (#2880):

  PDF   /CreationDate and /ModDate   D:20261018155816 vs D:20261018155817
  PNG   the `Creation Time` tEXt entry
  .vectorcraft  one byte of the stored date
  SVG   carries no time, and already repeated

`metadata::output_time` is the time a file records: `SOURCE_DATE_EPOCH` when it
holds a non-negative whole number of seconds, else the clock, as the
reproducible-builds specification defines it. A date a command is given
(`file.new {created}`, a save's `modified`) still wins over the variable, and a
blank, negative or non-numeric value is ignored so that an export is never
refused over the environment.

`metadata::now_unix` keeps the real clock and now has exactly two callers, both
of which must not be pinnable from the environment: the recovery heartbeat, and
the seed PDF encryption draws on.
2026-10-19 00:37:55 +07:00
bflatastic c9a2491ef7 Rotate View: a dial shows the angle while a drag rotates the view
Squashed from #2677 (its merge of main dropped).
2026-10-19 00:37:53 +07:00
bflatastic 2ae992870b Type: a zero width non-joiner is shaped in the face of the letters around it
Squashed from #2670 (its merge of main dropped).
2026-10-19 00:37:50 +07:00
dotCooCoo cec31c5f4a PDF/.ai import: a .ai saved without PDF compatibility opens when pages names every page
A .ai saved without PDF compatibility has only a placeholder page in
its PDF part. document.open reads such a file from its editing data,
but with `pages` given it failed with "the file holds only a
placeholder page", even for "1" on a file of one page.

When `pages` names every page of the file, document.open now reads
the editing data as it does without `pages`.

Docs: docs/mcp.md.
2026-10-19 00:37:38 +07:00
BFlat 3b527ca339 Merge pull request #2953 from nrq6993-a11y/stream/vc-feat3-1753-workspace-dock-width
#1753: saved workspaces restore dock panel width
2026-10-19 00:37:00 +07:00
BFlat 4f18f1c860 Merge pull request #2952 from nrq6993-a11y/stream/vc-feat-g1-busy-cursor
G1: show the system busy cursor during background open, save and export
2026-10-19 00:36:46 +07:00
bflatastic b529f356cc Tests: the dock-width test builds its UiState with the width, as clippy asks (#2953) 2026-10-19 00:28:48 +07:00
bflatastic 048875cc4e fmt (#2952) 2026-10-19 00:27:40 +07:00
nrq6993-a11yandCursor 57ddb71d97 #1753: saved workspaces restore dock panel width
Persist the main dock column width in workspace snapshots and reapply it when switching layouts.

Co-authored-by: Cursor <[email protected]>
2026-10-19 00:27:40 +07:00
nrq6993-a11yandCursor 49f7f5d7d8 G1: busy cursor during background open, save and export
Co-authored-by: Cursor <[email protected]>
2026-10-19 00:26:27 +07:00
BFlat aa9c70a7b1 Merge pull request #2956 from storytold/fix/zh-hant-param-errors
i18n: Traditional Chinese and Georgian rows for the parameter-check and pdfInfo errors (#2930, #2784, #2914)
2026-10-19 00:23:15 +07:00
bflatastic 278fba490a i18n(ka): the pdfInfo thumbnail page error (#2914) 2026-10-19 00:16:42 +07:00
bflatastic d807236bf2 i18n(zh-hant): the shared parameter-check errors, now that Traditional Chinese translates every message (#2930, #2784)
#2784 made zh-hant a language that translates every status and error message while #2930 added the shared parameter-check errors to the others; main's shared_param_check_errors_are_translated failed on zh-hant.
2026-10-19 00:15:35 +07:00
BFlat 9c1d1d56ed Merge pull request #2944 from dotCooCoo/fix/save-for-web-preview-folder-per-run
Save for Web: each browser preview is written to its own folder
2026-10-19 00:10:12 +07:00
BFlat 53bd154059 Merge pull request #2938 from dotCooCoo/fix/legacy-ai-names-windows-1252
Legacy .ai export: the title and spot color and gradient names are written in Windows-1252
2026-10-19 00:09:56 +07:00
BFlat 1f2e90a7b3 Merge pull request #2937 from dotCooCoo/fix/ai-image-left-out-warns
.ai import: the editing data reader warns about each image it can't read
2026-10-19 00:09:38 +07:00
BFlat c7f748c75c Merge pull request #2936 from dotCooCoo/fix/editing-data-hash-reads-utf16-desc
PDF and .ai import: editing data with a UTF-16 description is checked against its pages
2026-10-19 00:09:23 +07:00
BFlat 4de769175e Merge pull request #2935 from dotCooCoo/fix/issue-2830
Legacy .ai export: an F line follows each %AI5_EndRaster
2026-10-19 00:09:07 +07:00
BFlat 9a8e2ac6a0 Merge pull request #2930 from dotCooCoo/fix/number-param-error-translated
i18n: number, object id and date parameter errors are translated
2026-10-19 00:08:55 +07:00
BFlat 419ed87cff Merge pull request #2929 from dotCooCoo/fix/anchor-commands-refuse-a-bad-subpath
Paths: anchor commands refuse a subpath that is not a whole number
2026-10-19 00:08:43 +07:00
BFlat eeb9513bbb Merge pull request #2921 from dotCooCoo/fix/links-on-mapped-drives
Links: a document opened from a mapped drive finds links on its share
2026-10-19 00:08:31 +07:00
BFlat c092ca1a48 Merge pull request #2917 from dotCooCoo/fix/menu-commands-refuse-text-numbers
Transform Each, Rasterize, Mosaic, Trim Marks and Rearrange: a number given as text is refused
2026-10-19 00:08:16 +07:00
BFlat ba2a11a807 Merge pull request #2914 from dotCooCoo/fix/pdfinfo-reads-postscript
MCP: document.pdfInfo answers one page for an EPS, a .ps file or a PostScript .ai
2026-10-19 00:08:04 +07:00
BFlat f1757c7bda Merge pull request #2907 from dotCooCoo/fix/eps-tiff-predictor-all-depths
EPS and .ai import: images with TIFF predictor 2 draw their samples at every bit depth
2026-10-19 00:07:47 +07:00
BFlat e616f1fe5c Merge pull request #2784 from dotCooCoo/feat/zh-hant-messages
i18n: Traditional Chinese translates status-bar and error messages
2026-10-19 00:07:32 +07:00
BFlat 647d51eaf6 Merge pull request #2765 from dotCooCoo/feat/open-targa-files
File formats: Targa (.tga) files open and place
2026-10-19 00:07:17 +07:00
BFlat 0d34a58b7b Merge pull request #2943 from youngeuibae/page-api
web: window.vectorcraft, the control channel for the page
2026-10-19 00:07:07 +07:00
BFlat 3a685eae61 Merge pull request #2939 from MortenHusted/feat/select-by-color
Select: select.color picks objects by fill and stroke colour, no object needed
2026-10-19 00:06:55 +07:00
BFlat e4d94109a2 Merge pull request #2922 from dotCooCoo/fix/issue-2871
Type: a click away from the text being edited ends editing without making new text
2026-10-19 00:06:44 +07:00
BFlat 4a90d13fc0 Merge pull request #2916 from dotCooCoo/fix/issue-2881
Fields: arrow keys and the wheel step to the next whole unit, and Ctrl/Cmd steps to the next tenth
2026-10-19 00:06:26 +07:00
BFlat 8a2d0db225 Merge pull request #2915 from EricKuck/fix/ai-outer-glow-dilation
Effects: an Outer Glow grows its solid silhouette by a quarter raster pixel before the blur
2026-10-19 00:06:16 +07:00
BFlat e358bbfdfb Merge pull request #2928 from nrq6993-a11y/stream/vc-issue-2719-wysiwyg-zoom
Fix #2719: honor Display Print Size for status-bar 100% zoom
2026-10-19 00:06:00 +07:00
BFlat 7d7853d7fd Merge pull request #2931 from MortenHusted/fix/raster-scale-only
CLI, MCP: the raster scale no longer resizes DXF exports
2026-10-19 00:05:46 +07:00
BFlat 0a14001126 Merge pull request #2911 from nrq6993-a11y/stream/vc-issue-2646-floating-panel-opaque
Fix #2646: keep undocked shared-dock panels opaque
2026-10-19 00:05:29 +07:00
BFlat 910e33b796 Merge pull request #2927 from nrq6993-a11y/stream/vc-issue-2727-mask-editing-dim
Fix #2727: opacity-mask editing no longer dims the canvas
2026-10-19 00:04:45 +07:00
BFlat 9b64fce9ec Merge pull request #2913 from Higgs32584/fix/2707-cmyk-icc-output-tags
CMYK profiles carry every tag an ICC output profile requires (#2707)
2026-10-19 00:04:16 +07:00
BFlat cd35c27a53 Merge pull request #2941 from devius/ka-fixes
i18n(ka): Letter and Word Spacing as in the other Craft apps; "weren't read" in the catalog's usual form
2026-10-19 00:04:00 +07:00
dotCooCoo ab01a1a927 Save for Web: each browser preview is written to its own folder
Preview in Browser wrote every page to one folder,
VectorCraft Save for Web in the system's temporary folder. A second
preview of a document with the same title, from the same app or from
another one running, overwrote the first preview's page, and any image
file of the same name (PNG-8 and PNG-24 both write .png), before the
browser read them. The same overwrite made
dialogs::tests_save_for_web::preview_in_browser_opens_the_page fail now
and then, when another Save for Web test in the same binary, or a test
run in another checkout on the machine, previewed Untitled-1 at the
same time.

Each preview now goes to a new folder inside that one, named after the
process id and a count of the process's previews. A name that an
earlier process with the same id left there is skipped for the next
count. The page keeps its name and its images folder. A preview first
removes the entries of VectorCraft Save for Web that were last changed
an hour ago or earlier, which also clears the files the single folder
held before.

Docs: docs/target-app-parity.md.
2026-10-19 00:03:53 +07:00
dotCooCoo d054b5b4c7 Legacy .ai export: the title and spot color and gradient names are written in Windows-1252
Saving a version 7 .ai wrote the title, spot color names and gradient
names as the UTF-8 bytes of their characters: a spot color named Café
was written (Caf\303\251) in the header's custom colors and in each x.
Type and, since #2722, layer names are written in Windows-1252, the
encoding the file installs with TE.

These names now go through #2722's Windows-1252 path, so Café is
written (Caf\351). A character outside Windows-1252 is written as ?,
with one warning for all of these names. A spot color keeps one name
in the header, in each x and X and in gradient stops, and a gradient
keeps one name in its definition and in each Bg. Two spot colors or
two gradients whose names become the same that way are numbered (? 2).

Docs: docs/file-format-parity.md.
2026-10-19 00:03:52 +07:00
dotCooCoo ba32ef5b09 .ai import: the editing data reader warns about each image it can't read
An image in a .ai's editing data whose operands had no matrix, fewer
than 11 numbers, or a matrix of other than six numbers was left out
with no warning. So was one whose matrix maps it onto a line or a
point. The reader's warnings did not mention such an image, while the
reader warned "an image VectorCraft can't read was left out" for an
image whose size or bit depth it can't use.

Each of those exits now adds the same warning, as does the one where
the decoded pixels don't make an image.
2026-10-19 00:03:51 +07:00
dotCooCoo f0c2d8c890 PDF and .ai import: editing data with a UTF-16 description is checked against its pages
A PDF or PDF-compatible .ai saved with Preserve Editing carries the
native document as an embedded file, with a hash of its pages at the
end of the file's /Desc. The import read /Desc with from_utf8_lossy and
looked for "hash ". /Desc is a PDF text string, which may be written as
UTF-16BE with a byte order mark (ISO 32000-1 7.9.2.2). Written that
way it holds no UTF-8 "hash ", so the import found no hash, took the
editing data as intact and reopened the native document with no
warning, even after another app had changed the pages.

/Desc is now read with text_string, the importer's decoder for text
strings, so the hash is found in UTF-16BE, UTF-8 and PDFDocEncoding.
Changed pages open as artwork with the "changed in another app"
warning. The embedded file's /UF and /F names are decoded the same
way, so editing data named only in UTF-16BE is found.
A /Desc without a hash is still trusted, as before.
2026-10-19 00:03:51 +07:00
dotCooCoo e5c9054324 Legacy .ai export: an F line follows each %AI5_EndRaster
alongtheblue-sketch took 0.12.0's version 7 output of the SVGs in #2692
and #2830, edited it by hand and opened it in the reference app
(https://github.com/storytold/vectorcraft/pull/2801#issuecomment-6244853548).
Writing the image lines as #2801 does, with the bracketed matrix and
the 60-digit data lines, did not change the result there. With one
line, F, added after %AI5_EndRaster, the reference app opened the image
in its clipping group and the art after it in both files.

The writer now writes F after every %AI5_EndRaster: clipped, grouped
and loose images, flattened images and images in pattern tiles. No path
is open at that point, so the line paints nothing. VectorCraft's reader
opens the files with the same objects as before, and the file's
PostScript prolog draws the same page.
2026-10-19 00:03:50 +07:00
dotCooCoo 9fe0f03de2 i18n: number, object id and date parameter errors are translated
The errors of the parameter checks that many commands share had no
@msg row in any catalog: "`{key}` must be a number, not {v}" from
check_numbers (object.move, object.rotate, object.setBounds and the
other callers), "missing integer `{key}`" from index_param in
cmd/appearance.rs (appearance, effect and freeform gradient commands),
the three object id errors of checked_ids_param and checked_id, and
the Unix seconds error of date_param. A template
matches only the whole text, so the row for "`{key}` must be a
number" did not match the longer check_numbers message. These errors
showed in English in the status bar, and "no such object id {value}"
matched the general "no such object {0}" row and showed half in
English. The scan in complete_languages_translate_every_message does
not read messages built with bad(), so that test did not report them.

Each of the 6 messages now has a row in the 18 catalogs that translate
every status and error message and in Bulgarian, Danish, Indonesian and
Dutch, and "missing number `{key}`" has a row in Danish and Indonesian.
No English string changes.

Docs: docs/target-app-parity.md.
2026-10-19 00:03:48 +07:00
BFlat 429e147164 Merge pull request #2940 from ElPaule/i18n/de-missing-entries
i18n(de): add missing PSD error messages and effect Soft entry
2026-10-19 00:03:48 +07:00
dotCooCoo c0ec14b86d Paths: anchor commands refuse a subpath that is not a whole number
path.curvatureEdit, path.removeAnchor, path.convertAnchor,
path.reshapeSegment, path.split, path.setHandle and path.insertAnchor
read subpath as an unsigned integer and fell back to 0 for any other
value. A subpath of 1.5, -1 or "1" edited subpath 0 and the command
reported success. path.split read anchor and segment with as_u64 too
and treated any other value as absent, so a bad anchor beside a good
segment split at the segment.

These commands now refuse a subpath that is not a whole number of 0 or
more with "missing integer `subpath`", the error path.curvatureEdit,
path.removeAnchor, path.convertAnchor and path.reshapeSegment already
give for a bad anchor or segment, and path.split refuses such an anchor
or segment too. Without a subpath, or with null, they edit subpath 0 as
before. The document and the undo history stay as they were after each
error. The params text of the six commands that listed subpath as
required now marks it optional.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:47 +07:00
dotCooCoo 89a73abc38 Links: a document opened from a mapped drive finds links on its share
craft-docpath follows a network path only on the share the document is
on, and its caller contract asks for a document opened from a mapped
drive to be passed by its UNC path. fileio::document_path passed the
drive path as given, so a document opened as Z:\jobs\doc.vectorcraft
treated a link stored as \\server\share\img\logo.png, on the share
behind Z:, as on another computer: the image was missing, and
document.open, links.check and links.info gave refused: "the path is on
another computer".

On Windows, when a stored path is refused as on another computer and
the document is on a drive, document_path now reads the final path of
the document's folder (std::fs::canonicalize). When that names a share
(\\?\UNC\server\share\...), the path is checked again against the
document's path on that share. Links on other computers stay refused,
and paths that were followed resolve as before. Opening a document,
Link Info, links.check and the linked-file watch all use it. Other
systems are unchanged.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:47 +07:00
dotCooCoo cc8944ad2a Transform Each, Rasterize, Mosaic, Trim Marks and Rearrange: a number given as text is refused
Transform Each, Rasterize, Create Object Mosaic, Create Trim Marks and
Rearrange All Artboards read their optional numbers with f64_or, which
falls back to a default for a value that is not a JSON number.
object.transformEach {"scaleH": "50"} left the selected objects at
their size and reported success. Rasterize did the same with ppi,
Create Object Mosaic did the same with width, height, spacingX and
spacingY, Create Trim Marks did the same with offset, length, bleed and
weight, and Rearrange All Artboards did the same with spacing. Crop
Image cropped to the artboard when rect was a string or another value
that is not an array.

These commands now fail with "`<key>` must be a number, not <value>"
for a value that is not a number or null, as the transform commands
do, and change nothing. Transform Each checks scaleH, scaleV, moveH,
moveV and rotate. Crop Image refuses a rect that is not an array of
four numbers with "rect must be [x, y, width, height]", the message it
already gave for an array of the wrong length; a null rect crops to the
artboard, as a missing one does.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:46 +07:00
dotCooCoo 05264b2801 MCP: document.pdfInfo answers one page for an EPS, a .ps file or a PostScript .ai
document.pdfInfo refused PostScript (an EPS, a .ps file, or a .ai in
PostScript form), mostly with "this is a PostScript file ..., not a
PDF", although document.open and file.place read the same file
through the EPS reader as one page.

It now answers {pages: 1, needsPassword: false, pageInfo: [{width,
height, rotation: 0, boxes: {}}]}. The page is the file's bounding box
(%%HiResBoundingBox when it has one, a US Letter page without one),
read from its comments without running its program. thumbnail: 1 is a
PNG of what that page prints, read by the EPS reader without any
editing data the file carries. A program whose header is only %! is
read as PostScript, as clipboard.importEps reads it.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-19 00:03:45 +07:00
dotCooCoo 372887fc6c EPS and .ai import: images with TIFF predictor 2 draw their samples at every bit depth
The PostScript interpreter that reads EPS and PostScript .ai files
accepts a FlateDecode or LZWDecode filter with Predictor 2 at
BitsPerComponent 1, 2, 4, 8 and 16, but undid the horizontal
differencing only at 8 bits. At the other depths the filter gave back
the stored differences, and the image drew them instead of its
samples.

The predictor is now undone at every depth it accepts, as ISO 32000-1
§7.4.4.4 describes: each component adds the same component of the
pixel to its left, within its bit width, and each row starts again.
16-bit components add as big-endian 16-bit numbers. The padding bits
at the end of a row stay as they are, and 8-bit data takes the same
path as before. The PNG predictors are unchanged.

Docs: docs/target-app-parity.md.
2026-10-19 00:03:43 +07:00
dotCooCoo 841fc0cd51 i18n: Traditional Chinese translates status-bar and error messages
Squashed from #2784 (its merge of main dropped).
2026-10-18 23:50:58 +07:00
dotCooCoo d7e7066251 File formats: Targa (.tga) files open and place
Squashed from #2765 (its merge of main dropped).
2026-10-18 23:50:58 +07:00
focuerandClaude Opus 5.5 dc7216f0e4 web: rustfmt window.vectorcraft
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:50:58 +07:00
MortenHusted 36aecff81d Select: select.color reads compound paths and empty type as document.inspect does
A compound path holds its members, so select.color passed over it and
looked at the members instead, whose paint inspect doesn't report: a
black compound path (art with holes, as PDFs bring in) never matched
"#000000". It now matches by its own fill and stroke, and its members
never do. Type without characters reads as the default character fill,
as inspect reports it (CodeRabbit on #2939).
2026-10-18 23:50:58 +07:00
focuerandClaude Opus 5.5 9dbab52168 web: window.vectorcraft, the control channel for the page
The same JSON methods as the desktop control server (engine.execute, ui.inspect, ui.screenshot, …),
as promises on window.vectorcraft, like FilmCraft's window.filmcraft: an agent driving the editor in a
browser tab or an <iframe> uses the same commands as the CLI and the MCP server. openFile opens
bytes the way a dropped file opens; captureDownloads keeps saves in the page for readFile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:50:58 +07:00
dotCooCoo 09a6fbe470 Type: a click away from the text being edited ends editing without making new text
While a Type tool edited text, a click on blank canvas ended the
editing and also placed new point type at the click. Area Type and
Type on a Path did the same with a click on a path.

Such a click now only ends the editing. The typing session is
committed, empty point type that a click placed is discarded as before,
and the Type tool in use stays active; the next click makes new type.
The same applies when the editing began with a double-click from a
selection tool. A click on other type still edits it at once, and a
drag that draws an area-type frame still makes the frame.
2026-10-18 23:50:57 +07:00
dotCooCoo b30d80e056 Fields: arrow keys and the wheel step to the next whole unit, and Ctrl/Cmd steps to the next tenth
In number fields, Up/Down and a wheel notch added a whole step and kept
the fraction, so 5.1 mm stepped up to 6.1 mm. A plain step now goes to
the next whole unit in its direction (5.1 goes up to 6 and down to 5; 5
goes up to 6 and down to 4), and a Ctrl/Cmd step goes to the next tenth,
in the field's own unit. Repeated presses or notches of one size add or
take one step each, and presses and notches are applied in the order
they came. A Shift step still adds or takes ten and keeps the fraction.

The number fields that widgets.rs draws for the panels, the Control bar
and the dialogs (unit, percent, degree and count fields) all use this
step (widgets::step_value).
2026-10-18 23:50:57 +07:00
EricKuckandClaude Fable 5.1 1b8e3d9ae1 Outer Glow grows its solid silhouette by a quarter raster pixel before the blur, as the glows AI files export measure
Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-18 23:50:57 +07:00
bflatastic 12d107cc56 fmt (#2928) 2026-10-18 23:50:57 +07:00
MortenHusted eba9abc21e CLI, MCP: the raster scale no longer resizes DXF exports
`vectorcraft-cli run/convert --scale` and the MCP export tool's `scale`
are raster pixels per point, but they were passed to every export, and
DXF reads `scale` as drawing units per unit: a 2x PNG next to a DXF
doubled the drawing and left it unitless ($INSUNITS 0). They now go
only to raster formats. DXF's own scale stays reachable through the
format options.
2026-10-18 23:50:57 +07:00
bflatastic 88494319da fmt (#2911) 2026-10-18 23:50:57 +07:00
MortenHusted d25289a3be Select: select.color picks objects by fill and stroke colour, no object needed
Scripts had to know an object id before they could select art by colour
(select.same.* and select.magicWand start from one). select.color
{fill?, stroke?} takes colours as paint.setFill does and selects the
objects whose fill and stroke read as them in #rrggbb, as
document.inspect reports colours, with Select > Same's selection rules.

The label is translated in each of the 23 catalogs, which
complete_languages_translate_every_menu_string requires.
2026-10-18 23:50:57 +07:00
nrq6993-a11yandCursor 2025c357d9 Fix #2719: honor Display Print Size for status-bar 100% zoom
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:50:57 +07:00
nrq6993-a11yandCursor 16dcaa425b Fix #2727: opacity-mask editing no longer dims the canvas
Co-authored-by: Cursor <[email protected]>
2026-10-18 23:48:57 +07:00
nrq6993-a11yandCursor bf19ada5e1 Fix #2646: keep undocked shared-dock panels opaque
Floating groups on the canvas now always get a panel-colour backdrop from layout geometry when egui has not registered the area yet, and panel bodies paint their own fill so empty regions do not show through.

Co-authored-by: Cursor <[email protected]>
2026-10-18 23:48:57 +07:00
Higgs32584andClaude Opus 5.5 c5798f446e CMYK profiles carry every tag an ICC output profile requires (#2707)
The built-in CMYK profiles embedded in CMYK exports had only the
perceptual and colorimetric tables. ICC.1 requires an output profile to
also have the saturation tables (A2B2, B2A2) and the gamut tag (gamt).
Windows colour management rejects the profile without them ("Bitmap color
context is not valid"), so Microsoft Photos couldn't show a CMYK TIFF
that embedded it, while the RGB export opened fine.

Write the saturation tables (same as colorimetric) and a 9x9x9 gamut
table (in gamut where a Lab colour separates and returns within dE 3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 23:48:56 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 4675a1633e i18n(ka): Letter and Word Spacing as in the other Craft apps; "weren't read" in the catalog's usual form
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 23:48:55 +07:00
ElPaule d99b04e594 i18n(de): add missing PSD error messages and effect Soft entry 2026-10-18 23:48:54 +07:00
BFlat 0f6154e7b9 Merge pull request #2934 from devius/fonts-georgian-pin
Release: embed the current craft-fonts (adds Noto Sans Georgian, craft-fonts#27)
2026-10-18 23:45:27 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 fc594b51ee Release: build the web version with craft-fonts too (web-craft-fonts)
Without it the shared release workflow builds the web release without CRAFT_FONTS_DIR, so
web-fonts.txt's startup faces (Arabic, Thai, Georgian) are not served (CodeRabbit review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 20:29:04 +04:00
BFlat cdba0aac03 Merge pull request #2900 from nrq6993-a11y/stream/vc-issue-2723-reshape-falloff
Fix #2723: Reshape tool falloff follows the path, so nodes stretch progressively
2026-10-18 20:14:02 +07:00
BFlat f44c3e9593 Merge pull request #2895 from Silvertree2010/fix/h2559-unit-named-ft-resolves-to-feet-i
Resolve the "ft" unit suffix to Feet in Unit::named
2026-10-18 20:13:47 +07:00
BFlat c965f49fda Merge pull request #2905 from storytold/fix/2850-pencil-redraw
Pencil: a stroke that starts and ends on a selected path redraws the section between (Edit Selected Paths, #2850)
2026-10-18 20:13:22 +07:00
BFlat a10604bbc6 Merge pull request #2904 from storytold/fix/2860-recent-cards
New Document: Recent and Saved cards keep the orientation they were made in (#2860)
2026-10-18 20:13:13 +07:00
bflatastic 4f526b4e3f Reshape: the lengths along the path are measured once per drag, and the default reach stays the path's size (#2900)
anchor_arc_distance measured every segment again for each anchor, so a reshape cost the square of the anchor count; path.reshape now measures the positions once, and the two helpers only it used are gone. The default radius stays half the subpath's bounding-box diagonal, now measured along the path: half its length reached the far corners of a square, moving art the grab is nowhere near (reshape_moves_the_grabbed_point_and_its_neighbourhood).
2026-10-18 20:12:47 +07:00
NetterSilverandClaude Haiku 5.5 f2601137ab Resolve the "ft" unit suffix to Feet in Unit::named
Found while testing crates/doc/src/lib.rs:274 (Unit::named)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 20:12:47 +07:00
bflatastic 9938a31c9b Pencil: a stroke that starts and ends on a selected path redraws the section between (Edit Selected Paths, #2850)
path.freehand takes redraw: {id, within}: the stroke's ends are placed on the path, anchors added there, and the section between them replaced by the fitted stroke, the shorter way round a closed subpath; a stroke that doesn't end on the path still draws a new one. The Pencil and Paintbrush send it for a stroke that starts on a selected path's outline away from its ends, within Edit Selected Paths' distance.
2026-10-18 20:12:47 +07:00
bflatastic 5f37fccdce New Document: Recent and Saved cards keep the orientation they were made in, so one A3 card is chosen, not two (#2860)
The cards turned to the chosen orientation in every category, so a portrait and a landscape A3 in Recent looked the same and both read as chosen. Only the built-in presets' cards turn now.
2026-10-18 20:12:47 +07:00
nrq6993-a11yandCursor e8e88c14f2 Fix #2723: Reshape falloff uses arc length along the path
Co-authored-by: Cursor <[email protected]>
2026-10-18 20:12:47 +07:00
BFlat 2cdb0d2d53 Merge pull request #2893 from dylanmtaylor/chore/drop-competitor-references
docs: drop references to competing commercial software
2026-10-18 19:36:12 +07:00
Dylan M. Taylor fe16f852db docs: clarify readiness audience and tracked features baseline 2026-10-18 08:28:33 -04:00
BFlat 43bb588e5e Merge pull request #2897 from storytold/fix/2879-baselines
Canvas: selected point type's baseline marker runs under each line, centred and right-aligned type too (#2879)
2026-10-18 19:20:48 +07:00
BFlat a57eaa0782 Merge pull request #2896 from storytold/fix/2873-w-weights
SVG export: a Japanese face's numbered weight (W0–W9) is its font-weight, W6 600 (#2873)
2026-10-18 19:20:39 +07:00
BFlat 007df2ee24 Merge pull request #2877 from AJMakesStuff/main
Add interactive Mirror Repeat axis controls
2026-10-18 19:20:05 +07:00
BFlat 0955e6845c Merge pull request #2884 from nrq6993-a11y/stream/vc-issue-2576-font-poll
Fix #2576: poll for fonts that font managers activate while the app is in front
2026-10-18 19:19:49 +07:00
BFlat 4bafe834a6 Merge pull request #2887 from ham9zah/fix/arabic-alternate-glyph-highlight
Part of #394: required shaping is not an alternate glyph
2026-10-18 19:19:33 +07:00
BFlat 87603e4c35 Merge pull request #2876 from ham9zah/fix/selection-clip-group-outlines
G1: test that a group of clipped images shows only its masks and their bounds
2026-10-18 19:19:19 +07:00
BFlat e60882a3a1 Merge pull request #2870 from lewandowskit/pl-terminologia
i18n(pl): Artboard is "obszar kompozycji", Guides "linie pomocnicze"
2026-10-18 19:19:05 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 25a987a94f Release: embed the current craft-fonts (adds Noto Sans Georgian, craft-fonts#27)
The release and FreeBSD workflows pinned craft-fonts 3d9ab3a1, two commits before #27,
so release builds had no Georgian face for the ka interface.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-18 16:09:28 +04:00
bflatastic bb14031aa3 Canvas: selected point type's baseline marker runs under each line, centred and right-aligned type too (#2879)
The marker ran from the anchor to the right edge of the bounds, so centred type showed half a line and right-aligned type none, and rotated type a level line. It now draws each line's baseline in text space (TextObject::baselines, the lines a click selects the type on), turned with the type.
2026-10-18 19:02:52 +07:00
Dylan M. Taylor 090659b78b docs: drop references to competing commercial software 2026-10-18 07:52:58 -04:00
194 changed files with 10146 additions and 580 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/crates/affinity/fuzz"
- "/crates/plugins/example"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+1 -1
View File
@@ -35,5 +35,5 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
require-craft-fonts: true
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
run-package-check: true
+2 -1
View File
@@ -26,7 +26,8 @@ jobs:
with:
app-name: vectorcraft
app-title: VectorCraft
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+1
View File
@@ -167,6 +167,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `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 | |
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
Generated
+1
View File
@@ -5007,6 +5007,7 @@ dependencies = [
"js-sys",
"log",
"rfd",
"serde_json",
"vectorcraft-engine",
"vectorcraft-format",
"vectorcraft-text",
+1 -1
View File
@@ -65,7 +65,7 @@ wat = "1.228"
# Without `text` (glyph runs, through glifo and a second skrifa): the renderer draws the outlines
# vectorcraft-text gives it. The other default features stay.
vello_cpu = { version = "0.2", default-features = false, features = ["std", "png", "u8_pipeline", "f32_pipeline"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp", "tga"] }
# Without `text` and the system fonts (fontdb, harfrust and skrifa, at other versions than ours):
# SVG import reads `<text>` from the XML itself and never gives usvg a font. A test of the SVG
# crate turns `text` on.
+9 -12
View File
@@ -11,11 +11,11 @@
<h1 align="center">VectorCraft</h1>
<p align="center">
<b>Vector illustration; an open-source, clean-room reimplementation of Adobe Illustrator, rebuilt in pure Rust.</b>
<b>Vector illustration; an open-source, modern vector art application, built in pure Rust.</b>
</p>
<p align="center">
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
A fast, open-source vector illustration workflow. It runs natively on
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
</p>
@@ -147,7 +147,7 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
## Why VectorCraft
- **Familiar.** Illustrator's layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: the Pen, Direct Selection,
Pathfinder, Smart Guides, Appearance, Swatches, Layers and more. You already know how to use it.
- **Fast.** Multithreaded SIMD rendering off the UI thread. 20,000 shapes render in about 27 ms at
full retina resolution while the interface stays at 120 fps.
@@ -224,9 +224,8 @@ VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the st
and what's next; [`docs/target-app-parity.md`](docs/target-app-parity.md) the full parity assessment and
[`docs/gaps.md`](docs/gaps.md) the ranked list of what's missing.
**Where we are (2026-10-10): alpha.** About 88% of Illustrator 2026's menu items, tools, effects and formats exist
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% "ready for real work" for a mainstream illustrator (~62% against
all of Illustrator): everyday
**Where we are (2026-10-10): alpha.** About 88% of core menu items, tools, effects and formats exist
(92.5% of menu items do something), feature depth is ~75%, and we estimate ~65% ready for real work for a mainstream illustrator (~62% across all tracked features): everyday
vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
@@ -240,20 +239,20 @@ and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-
The interface
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against Illustrator's public documentation;
The scores are self-assessed from our code against public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
**What's missing:**
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
- about half of the raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- scripting, and the rest of Variables (image and graph kinds, dataset import);
- generative AI features.
**Where we're going:** next is the interaction-fidelity pass, `.ai` files from real users and start-up stability,
alongside the raster-effects package; then type, the Illustrator 2026 additions and 3D. The ranked list is in
alongside the raster-effects package; then type, recent additions and 3D. The ranked list is in
[`docs/gaps.md`](docs/gaps.md).
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
@@ -372,8 +371,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+4
View File
@@ -134,6 +134,10 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock (#2880). Headline numbers unchanged. |
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now (1/2 to 1/128) and the minor ticks between them are binary too, while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
+17 -4
View File
@@ -79,14 +79,16 @@ USAGE:
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
is a template, written as file.saveAsTemplate writes it). Prints one JSON result per step.
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
--artboard or --range).
@@ -278,7 +280,8 @@ fn convert(args: &[String]) -> Result<(), String> {
} else {
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
let mut params = with_pixel_scale(params, &output, scale);
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
@@ -302,6 +305,16 @@ fn is_template(path: &str) -> bool {
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
}
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
/// its units unset.
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
params["scale"] = json!(scale);
}
params
}
fn info(args: &[String]) -> Result<(), String> {
let file = args.first().ok_or("info needs a FILE")?;
let mut h = Headless::new();
@@ -391,7 +404,7 @@ fn run(args: &[String]) -> Result<(), String> {
let r = if is_template(&path) {
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
} else {
h.call("app.export", json!({"path": path, "scale": scale}))
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
};
let r = r.map_err(|e| format!("export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?;
+22
View File
@@ -58,6 +58,28 @@ fn run_batch_exports() {
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
}
#[test]
fn scale_sizes_raster_exports_only() {
// --scale is pixels per point. DXF has a `scale` of its own (drawing units per mm) that would
// resize the drawing and leave its units unset.
let svg = tmp("scale.svg");
std::fs::write(&svg, r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="30" height="20"/></svg>"#)
.unwrap();
let svg = svg.to_str().unwrap();
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
let ok = |args: &[&str]| {
let out = Command::new(BIN).args(args).output().unwrap();
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
ok(&["convert", svg, plain.to_str().unwrap()]);
ok(&["convert", svg, scaled.to_str().unwrap(), "--scale", "2"]);
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "convert --scale 2 changed the DXF");
ok(&["run", "--in", svg, "--export", scaled.to_str().unwrap(), "--export", png.to_str().unwrap(), "--scale", "2"]);
assert!(std::fs::read(&scaled).unwrap() == std::fs::read(&plain).unwrap(), "run --scale 2 changed the DXF");
// 100 pt at scale 2 → 200 px (IHDR width).
assert_eq!(std::fs::read(&png).unwrap()[16..20], 200u32.to_be_bytes());
}
#[test]
fn run_reports_errors() {
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
+1
View File
@@ -26,6 +26,7 @@ log = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
rfd = { workspace = true }
wasm-bindgen = "0.2"
serde_json = { workspace = true }
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlIFrameElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "PointerEvent", "TouchEvent", "AddEventListenerOptions", "DomRect", "DragEvent", "Storage", "Navigator", "RequestInit", "Response", "AbortController", "AbortSignal"] }
+184
View File
@@ -0,0 +1,184 @@
//! `window.vectorcraft`: the control channel for the page, as promises.
//!
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
//! the CLI and the MCP server:
//!
//! ```js
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
//! await vectorcraft.request("ui.inspect") // control method → its result
//! await vectorcraft.commands() // command list
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
//! vectorcraft.captureDownloads(true) // saves stay in the page…
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
//! ```
//!
//! Results resolve with the method's `result`; errors reject with an `Error`.
use std::cell::{Cell, RefCell};
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use serde_json::{Value, json};
use vectorcraft_ui_egui::ControlRequest;
use wasm_bindgen::prelude::*;
use crate::saved_files::SavedFiles;
use crate::web::Inbox;
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
thread_local! {
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
static CAPTURE: Cell<bool> = const { Cell::new(false) };
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
}
/// Wire the page's requests to the app: control requests go to `tx`, opened files to `inbox`.
/// After a graphics restart the app keeps its channel: `tx` is `None` and only the context changes.
pub fn connect(tx: Option<Sender<ControlRequest>>, inbox: Inbox, ctx: egui::Context) {
if let Some(tx) = tx {
TX.with(|t| *t.borrow_mut() = Some(tx));
}
INBOX.with(|i| *i.borrow_mut() = Some(inbox));
CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
fn repaint() {
CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
}
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
pub fn capture(path: &str, bytes: &[u8]) -> bool {
if !CAPTURE.with(Cell::get) {
return false;
}
SAVED.with(|s| s.borrow_mut().keep(path, bytes));
true
}
fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
}
/// Settle the promises of answered requests (called every frame).
pub fn poll_replies() {
WAITING.with(|w| {
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
Ok(v) => {
if v.get("ok").and_then(Value::as_bool) == Some(true) {
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
} else {
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
}
false
}
Err(TryRecvError::Empty) => true,
Err(TryRecvError::Disconnected) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
false
}
});
});
}
fn request(method: &str, params: Value) -> js_sys::Promise {
let method = method.to_string();
let p = js_sys::Promise::new(&mut |resolve, reject| {
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
if sent {
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
} else {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
}
});
repaint();
p
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
/// Install `window.vectorcraft`.
pub fn install() -> Result<(), JsValue> {
let obj = js_sys::Object::new();
set_fn(
&obj,
"request",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(&params)))
.into_js_value(),
)?;
set_fn(
&obj,
"execute",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
request("engine.execute", json!({"command": command, "params": from_js(&params)}))
})
.into_js_value(),
)?;
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
set_fn(
&obj,
"screenshot",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(&params))).into_js_value(),
)?;
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(|name: String, bytes: js_sys::Uint8Array| {
let queued = INBOX.with(|i| {
i.borrow().as_ref().is_some_and(|inbox| {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes.to_vec()));
true
})
});
repaint();
JsValue::from_bool(queued)
})
.into_js_value(),
)?;
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
set_fn(
&obj,
"files",
Closure::<dyn Fn() -> JsValue>::new(|| {
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
})
.into_js_value(),
)?;
set_fn(
&obj,
"readFile",
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
SAVED.with(|s| {
s.borrow()
.get(&name)
.map(|b| js_sys::Uint8Array::from(b).into())
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
})
})
.into_js_value(),
)?;
let w = web_sys::window().ok_or("no window")?;
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
Ok(())
}
+4
View File
@@ -16,12 +16,16 @@
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?lang=es` (any
//! language code) stands in for the browser's languages when the interface language is Automatic.
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod fonts;
#[cfg(target_arch = "wasm32")]
mod locks;
#[cfg(any(target_arch = "wasm32", test))]
mod pencil;
#[cfg(any(target_arch = "wasm32", test))]
mod saved_files;
#[cfg(target_arch = "wasm32")]
mod web;
+70
View File
@@ -0,0 +1,70 @@
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
/// Saved files kept (the oldest go first).
pub const MAX_SAVED: usize = 16;
/// The last component of a `/` or `\` path.
fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
}
/// Files by name, newest last: saving a name again replaces it.
#[derive(Default)]
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
impl SavedFiles {
/// Keep `bytes` under the last component of `path`.
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
let name = file_name(path).to_string();
self.0.retain(|(n, _)| *n != name);
self.0.push((name, bytes.to_vec()));
let extra = self.0.len().saturating_sub(MAX_SAVED);
self.0.drain(..extra);
}
/// A kept file by its name, or by a path ending in it.
pub fn get(&self, name: &str) -> Option<&[u8]> {
let wanted = file_name(name);
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
}
/// `(name, size)` of each kept file, oldest first.
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_by_name_replaces_and_reads_back_by_path() {
let mut s = SavedFiles::default();
s.keep("exports/poster.png", b"one");
s.keep(r"C:\out\poster.png", b"two");
s.keep("notes.txt", b"three");
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("missing.png"), None);
}
#[test]
fn drops_the_oldest_past_the_cap() {
let mut s = SavedFiles::default();
for i in 0..MAX_SAVED + 3 {
s.keep(&format!("f{i}.png"), &[0]);
}
assert_eq!(s.list().count(), MAX_SAVED);
assert_eq!(s.get("f0.png"), None);
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
}
#[test]
fn a_trailing_separator_keeps_the_whole_name() {
let mut s = SavedFiles::default();
s.keep("odd/", b"x");
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
}
}
+16 -2
View File
@@ -19,7 +19,7 @@ use wasm_bindgen::JsCast as _;
use crate::locks::WebLocks;
use crate::pencil::SharedPointers;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
pub(crate) type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
/// whether Shift was held: where dropped files land.
@@ -122,9 +122,18 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
// The old services asked the old context for frames.
app.services = services;
app.status("The graphics were lost and have been restarted");
crate::api::connect(None, host.inbox.clone(), ctx.clone());
app
}
None => VectorcraftApp::new(Session::new(), services),
None => {
// `window.vectorcraft`: the page's control channel (crate::api).
let (control_tx, control_rx) = std::sync::mpsc::channel();
crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone());
if let Err(e) = crate::api::install() {
log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e));
}
VectorcraftApp::new(Session::new(), services).with_control(control_rx)
}
};
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
}),
@@ -386,6 +395,7 @@ impl eframe::App for WebShell {
}
}
app.logic(ctx);
crate::api::poll_replies();
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -399,6 +409,7 @@ impl eframe::App for WebShell {
if let Some(app) = &mut self.app {
app.ui(ui);
}
crate::api::poll_replies();
}
}
@@ -544,6 +555,9 @@ impl RecoveryStore for BrowserStore {
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
if crate::api::capture(path, bytes) {
return Ok(());
}
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")?;
+112
View File
@@ -1090,4 +1090,116 @@ mod tests {
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
}
/// The MIME types a Linux desktop gives each extension in `OPEN_EXTS` but `UNASSOCIATED_EXTS`
/// (one row per type): shared-mime-info's, or one that `ai.storyteller.vectorcraft.mime.xml`
/// declares.
const LINUX_TYPES: &[(&str, &str)] = &[
("vectorcraft", "application/x-vectorcraft"),
("drawcraft", "application/x-vectorcraft"),
("svg", "image/svg+xml"),
("svgz", "image/svg+xml-compressed"),
("pdf", "application/pdf"),
("ai", "application/illustrator"), // brand-ok: shared-mime-info's type
("ait", "application/x-ai-template"),
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("webp", "image/webp"),
("tif", "image/tiff"),
("tiff", "image/tiff"),
("bmp", "image/bmp"),
("vctemplate", "application/x-vectorcraft-template"),
("dxf", "image/vnd.dxf"),
("emf", "image/emf"),
("wmf", "image/wmf"),
("eps", "image/x-eps"),
("af", "application/x-affinity"),
("afdesign", "application/x-affinity"),
("afpub", "application/x-affinity"),
];
/// The types in `LINUX_TYPES` that shared-mime-info defines. The MIME package declares every
/// other one.
const LINUX_SHARED_TYPES: &[&str] = &[
"image/svg+xml",
"image/svg+xml-compressed",
"application/pdf",
"application/illustrator", // brand-ok: shared-mime-info's type
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/tiff",
"image/bmp",
"image/vnd.dxf",
"image/emf",
"image/wmf",
"image/x-eps",
];
/// Extensions in `LINUX_TYPES` whose type the MIME package matches by file content and not by a
/// glob, with the package's `<magic>` rule for that type (its lines joined, indentation removed).
const LINUX_BY_CONTENT: &[(&str, &str)] = &[(
"af",
// `MAGIC` at offset 0 and `DOCUMENT_KIND` at offset 8 (crates/affinity/src/container.rs).
r#"<magic><match type="string" offset="0" value="\x00\xff\x4b\x41"><match type="string" offset="8" value="nsrP"/></match></magic>"#,
)];
/// The desktop entry lists a MIME type for every extension in `OPEN_EXTS` but
/// `UNASSOCIATED_EXTS`, and the app's MIME package declares the types shared-mime-info doesn't
/// have.
#[test]
fn the_linux_desktop_entry_opens_every_readable_format() {
let desktop = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.desktop");
let mime = include_str!("../../../packaging/linux/ai.storyteller.vectorcraft.mime.xml");
let listed: Vec<&str> =
desktop.lines().find_map(|l| l.strip_prefix("MimeType=")).unwrap_or_default().split(';').filter(|t| !t.is_empty()).collect();
// Each type the MIME package declares, with the extensions of its globs and its `<magic>`
// rule (empty when it has none) in the form `LINUX_BY_CONTENT` gives.
let declared: Vec<(&str, Vec<&str>, String)> = mime
.split("<mime-type type=\"")
.skip(1)
.map(|block| {
let body = block.split("</mime-type>").next().unwrap_or_default();
let globs = body.split("<glob pattern=\"*.").skip(1).filter_map(|g| g.split('"').next()).collect();
let magic = match (body.find("<magic"), body.find("</magic>")) {
(Some(start), Some(end)) => body[start..end + "</magic>".len()].lines().map(str::trim).collect(),
_ => String::new(),
};
(block.split('"').next().unwrap_or_default(), globs, magic)
})
.collect();
let mut associated: Vec<&str> = fileio::OPEN_EXTS.iter().copied().filter(|e| !fileio::UNASSOCIATED_EXTS.contains(e)).collect();
let mut typed: Vec<&str> = LINUX_TYPES.iter().map(|(e, _)| *e).collect();
associated.sort_unstable();
typed.sort_unstable();
typed.dedup();
assert_eq!(typed, associated, "a MIME type for each extension the packaging associates with the app");
for (e, t) in LINUX_TYPES {
assert!(listed.contains(t), "the desktop entry doesn't list {t} (.{e})");
let package = declared.iter().find(|(name, ..)| name == t);
if LINUX_SHARED_TYPES.contains(t) {
assert!(package.is_none(), "{t} is shared-mime-info's, and the MIME package declares it again");
continue;
}
let Some((_, globs, magic)) = package else { panic!("the MIME package doesn't declare {t} (.{e})") };
if let Some((_, rule)) = LINUX_BY_CONTENT.iter().find(|(content, _)| content == e) {
assert!(!globs.contains(e), "{t} matches .{e} files by content only");
assert_eq!(magic, rule, "{t}'s magic rule for .{e} files");
} else {
assert!(globs.contains(e), "{t} has no *.{e} glob");
}
}
for t in &listed {
assert!(LINUX_TYPES.iter().any(|(_, known)| known == t), "the desktop entry lists {t}, which the app doesn't open");
}
for (t, globs, _) in &declared {
assert!(listed.contains(t), "the desktop entry doesn't list the declared {t}");
for g in globs {
assert!(LINUX_TYPES.contains(&(*g, *t)), "{t}'s *.{g} isn't a file the app opens as {t}");
}
}
}
}
+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 18C8 6 16 6 21 14"/><rect x="3.6" y="11.6" width="3.6" height="3.6"/><rect x="10.2" y="6.7" width="3.6" height="3.6"/><rect x="16.8" y="9.2" width="3.6" height="3.6"/></svg>

After

Width:  |  Height:  |  Size: 369 B

+4
View File
@@ -129,3 +129,7 @@ sync_github_name = true
[people.makesomethingshit]
sync_github_name = true
[people.MiQ71]
real_name = "Mirko Kerezovic"
sync_github_name = false
+149
View File
@@ -56,6 +56,84 @@ struct Sub {
closed: bool,
}
/// A subpath close enough to a circle to preserve as a native DXF CIRCLE/ARC. VectorCraft's
/// ellipse helper uses the standard four-cubic circle approximation, so recognition needs a small
/// relative tolerance rather than exact control-point tests.
struct CircularSub {
centre: Point,
radius: f64,
samples: Vec<Point>,
}
fn cubic_point(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
let u = 1.0 - t;
let a = u * u * u;
let b = 3.0 * u * u * t;
let c = 3.0 * u * t * t;
let d = t * t * t;
Point::new(a * p0.x + b * p1.x + c * p2.x + d * p3.x, a * p0.y + b * p1.y + c * p2.y + d * p3.y)
}
/// Conservatively fit a curved subpath to one circle. Straight segments are never promoted. The
/// 5e-4 radial tolerance is just above the maximum radial error of the usual kappa cubic circle
/// approximation (~2.7e-4 r), while rejecting visibly elliptical or arbitrary cubic curves.
fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
if sub.segs.is_empty() || sub.segs.iter().any(|s| matches!(s, Seg::Line(_))) {
return None;
}
let mut samples = vec![sub.start];
let mut from = sub.start;
for seg in &sub.segs {
let Seg::Cubic(a, b, to) = *seg else { return None };
for i in 1..=8 {
samples.push(cubic_point(from, a, b, to, f64::from(i) / 8.0));
}
from = to;
}
if samples.len() < 4 {
return None;
}
let mut anchors = vec![sub.start];
anchors.extend(sub.segs.iter().map(|s| s.end()));
if sub.closed && anchors.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
anchors.pop();
}
let (p0, p1, p2) = if anchors.len() >= 3 {
(anchors[0], anchors[anchors.len() / 3], anchors[anchors.len() * 2 / 3])
} else {
(samples[0], samples[samples.len() / 2], *samples.last()?)
};
let d = 2.0 * (p0.x * (p1.y - p2.y) + p1.x * (p2.y - p0.y) + p2.x * (p0.y - p1.y));
let scale2 = p0.distance(p1).max(p1.distance(p2)).max(p2.distance(p0)).powi(2);
if !d.is_finite() || d.abs() <= scale2 * 1e-9 {
return None;
}
let q0 = p0.x * p0.x + p0.y * p0.y;
let q1 = p1.x * p1.x + p1.y * p1.y;
let q2 = p2.x * p2.x + p2.y * p2.y;
let centre = Point::new(
(q0 * (p1.y - p2.y) + q1 * (p2.y - p0.y) + q2 * (p0.y - p1.y)) / d,
(q0 * (p2.x - p1.x) + q1 * (p0.x - p2.x) + q2 * (p1.x - p0.x)) / d,
);
let radius = centre.distance(p0);
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-9 {
return None;
}
let tolerance = (radius * 5e-4).max(1e-6);
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
return None;
}
Some(CircularSub { centre, radius, samples })
}
fn angle_deg(p: Point, centre: Point) -> f64 {
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
}
fn on_ccw_arc(start: f64, end: f64, angle: f64) -> bool {
(angle - start).rem_euclid(360.0) <= (end - start).rem_euclid(360.0) + 1e-7
}
/// The subpaths of `bp` (quadratics raised to cubics).
fn subpaths(bp: &BezPath) -> Vec<Sub> {
let mut out = vec![];
@@ -576,6 +654,10 @@ impl<'a> Scene<'a> {
pts.pop();
}
self.polyline(&pts, sub.closed, style, width);
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
// its width. Non-circular curves continue through the existing fallback.
} else if v.handles() {
self.spline(&sub, style);
} else {
@@ -636,6 +718,73 @@ impl<'a> Scene<'a> {
self.e.entity("SEQEND", end, h, &layer, style, "");
}
/// Write a safely recognized circular subpath as a native DXF CIRCLE or ARC. Returns false
/// when the curve is not circular enough, leaving the caller to write it as a spline/polyline.
fn circular(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
let geometrically_closed = last.distance(sub.start) <= 1e-9;
// ClosePath can add an implicit straight closing edge. Do not replace that geometry with
// a full CIRCLE; likewise, an open curve that returns to its start is not an ARC. Check
// before fitting, updating extents or allocating an entity handle.
if sub.closed != geometrically_closed {
return false;
}
let Some(circle) = circular_subpath(sub) else { return false };
let centre = self.to_dxf * circle.centre;
let radius = circle.radius * self.k.abs();
if !centre.x.is_finite() || !centre.y.is_finite() || !radius.is_finite() || radius <= 1e-12 {
return false;
}
// Count sampled points in the drawing extents. For a full circle include all cardinal
// points exactly; for an arc, the dense samples conservatively cover its visible span.
if sub.closed {
let r = circle.radius;
for p in [
Point::new(circle.centre.x - r, circle.centre.y),
Point::new(circle.centre.x + r, circle.centre.y),
Point::new(circle.centre.x, circle.centre.y - r),
Point::new(circle.centre.x, circle.centre.y + r),
] {
self.pt(p);
}
} else {
for p in &circle.samples {
self.pt(*p);
}
}
let layer = self.layer.clone();
let h = self.handle();
if sub.closed {
self.e.entity("CIRCLE", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
self.e.xyz(10, centre.x, centre.y);
self.e.num(40, radius);
self.e.normal();
return true;
}
let start = self.to_dxf * sub.start;
let end = self.to_dxf * last;
let middle = self.to_dxf * circle.samples[circle.samples.len() / 2];
let mut a0 = angle_deg(start, centre);
let mut a1 = angle_deg(end, centre);
let am = angle_deg(middle, centre);
if !on_ccw_arc(a0, a1, am) {
std::mem::swap(&mut a0, &mut a1);
}
self.e.entity("ARC", h, fixed::MODEL_RECORD, &layer, style, "AcDbCircle");
self.e.xyz(10, centre.x, centre.y);
self.e.num(40, radius);
self.e.normal();
if self.o.version.handles() {
self.e.raw(100, "AcDbArc");
}
self.e.num(50, a0);
self.e.num(51, a1);
true
}
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
/// passes through every anchor (straight segments become straight cubics).
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
+73 -14
View File
@@ -143,8 +143,8 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(
kinds(&out),
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"],
"the fill's outline and hatch, then the stroke, then the ellipse's stroke"
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"],
"the fill's outline and hatch, then the stroke, then the circle's stroke"
);
let e = entities(&out);
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
@@ -164,11 +164,9 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
assert_eq!((get(&e[0], 370), get(&e[2], 370)), (None, Some("35")));
let hatch = &e[1];
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
// The ellipse: four cubics through clamped knots.
let spline = &e[3];
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
assert_eq!(get(spline, 70), Some("9"), "closed planar spline (#1299): 1 = closed, 8 = planar");
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
// The circular ellipse is preserved as a native circle (#1299).
let circle = &e[3];
assert_eq!((all(circle, 10), all(circle, 20), all(circle, 40)), (vec![150.0], vec![50.0], vec![30.0]));
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
assert_eq!(get(&entities(&r14)[2], 43), Some("1"));
@@ -177,16 +175,77 @@ fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
// R13 has splines and hatches, and polylines of vertices.
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
let poly = ["POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND"];
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["SPLINE"]].concat());
// R12: polylines only; the fill is its outline, the ellipse flattened.
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["CIRCLE"]].concat());
// R12: visible stroke widths live on old POLYLINE entities, including curved strokes.
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let k = kinds(&r12);
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened");
assert_eq!(k.iter().filter(|k| *k == "CIRCLE").count(), 0);
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
}
#[test]
fn circles_and_circular_arcs_are_native_dxf_entities() {
let d = doc_with(|d| {
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
let c = Point::new(120.0, 50.0);
let r = 30.0;
let k = r * shapes::KAPPA;
let mut arc = vectorcraft_geom::BezPath::new();
arc.move_to(Point::new(c.x + r, c.y));
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
vec![
path(d, circle, Paint::None, Paint::solid(Color::BLACK), 1.0),
path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0),
]
});
for version in DxfVersion::ALL.into_iter().filter(|version| *version != DxfVersion::R12) {
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["CIRCLE", "ARC"], "{version:?}");
assert_eq!((all(&e[0], 10), all(&e[0], 20), all(&e[0], 40)), (vec![50.0], vec![50.0], vec![30.0]), "{version:?}");
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
}
// An actual ellipse is not close enough to a circle and remains a spline.
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
}
#[test]
fn r12_stroked_circles_keep_polyline_width() {
let d = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 3.0)]);
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(kind(&e[0]), "POLYLINE");
assert_eq!((get(&e[0], 40), get(&e[0], 41)), (Some("3"), Some("3")));
assert!(!e.iter().any(|entity| kind(entity) == "CIRCLE"));
}
#[test]
fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
let d = doc_with(|d| {
let c = Point::new(50.0, 50.0);
let r = 30.0;
let k = r * shapes::KAPPA;
let mut arc = vectorcraft_geom::BezPath::new();
arc.move_to(Point::new(c.x + r, c.y));
arc.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
arc.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
arc.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
arc.close_path();
vec![path(d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
let modern = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&modern), ["SPLINE"]);
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
assert!(kinds(&r12).iter().any(|k| k == "POLYLINE"));
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
}
#[test]
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
let cases = [(true, true), (true, false), (false, false), (false, true)];
@@ -353,7 +412,7 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
// Each fill and stroke: its outlines, then its hatch.
let fill = ["LWPOLYLINE", "HATCH"];
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["SPLINE", "SPLINE", "HATCH"]].concat());
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
// The rectangle's stroke: an outer and an inner boundary.
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
// Preserve Appearance outlines only the strokes a line can't draw.
@@ -362,9 +421,9 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
st.align = vectorcraft_doc::StrokeAlign::Outside;
}
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["SPLINE"]].concat());
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"]);
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
}
@@ -451,7 +510,7 @@ fn crop_leaves_out_art_outside_the_region() {
let d = shapes_doc();
// Only the ellipse (120..180) reaches into 115..200.
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
assert_eq!(kinds(&export(&d, &o).unwrap()), ["CIRCLE"]);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
}
+19 -2
View File
@@ -320,9 +320,14 @@ fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
const A2B_GRID: usize = 9;
const B2A_GRID: usize = 17;
/// Grid points per Lab channel of the gamut table.
const GAMUT_GRID: usize = 9;
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
/// It carries every tag ICC.1 requires of an output profile: the tables for all three intents
/// and the gamut tag (Windows rejects a CMYK profile without them, so Photos couldn't show a CMYK
/// TIFF that embedded one, #2707).
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
@@ -337,6 +342,13 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
}
// Gamut: 0 where a colour separates and comes back within a ΔE of 3, else out of gamut.
let mut gamut = Vec::with_capacity(GAMUT_GRID.pow(3));
for i in 0..GAMUT_GRID.pow(3) {
let [l, a, b] = [2, 1, 0].map(|d| unit(i / GAMUT_GRID.pow(d) % GAMUT_GRID, GAMUT_GRID));
let want = lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0);
gamut.push(if lab::delta_e2000(want, to_lab(from_lab(want))) <= 3.0 { 0 } else { u16::MAX });
}
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
let mut grid_points = [0u8; 16];
@@ -346,8 +358,9 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
num_output_channels: outputs as u8,
grid_points,
clut: Some(LutStore::Store16(clut)),
// A curves sit on the device side, B curves on the PCS side.
a_curves: identity(4),
// A curves sit on the device side (the gamut tag's is its one output), B curves on the
// PCS side.
a_curves: identity(inputs + outputs - 3),
b_curves: identity(3),
m_curves: vec![],
matrix: Matrix3d::IDENTITY,
@@ -356,6 +369,7 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
};
let a2b = table(4, 3, A2B_GRID, a2b);
let b2a = table(3, 4, B2A_GRID, b2a);
let gamut = table(3, 1, GAMUT_GRID, gamut);
let mut p = ColorProfile::default();
p.profile_class = ProfileClass::OutputDevice;
p.color_space = DataColorSpace::Cmyk;
@@ -364,9 +378,12 @@ pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, fr
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
p.media_white_point = Some(p.white_point);
p.lut_a_to_b_perceptual = Some(a2b.clone());
p.lut_a_to_b_saturation = Some(a2b.clone());
p.lut_a_to_b_colorimetric = Some(a2b);
p.lut_b_to_a_perceptual = Some(b2a.clone());
p.lut_b_to_a_saturation = Some(b2a.clone());
p.lut_b_to_a_colorimetric = Some(b2a);
p.gamut = Some(gamut);
labelled(p, name)
}
+17
View File
@@ -135,3 +135,20 @@ fn written_profiles_align_their_tags() {
}
}
}
/// #2707: the CMYK profiles written here carry every tag ICC.1 requires of an output profile (the
/// tables of all three intents and the gamut tag): Windows rejects one without them, and Photos
/// then can't show a CMYK TIFF that embeds it.
#[test]
fn cmyk_profiles_have_the_tags_of_an_output_profile() {
for name in BUILTIN_CMYK {
let icc = icc_bytes(name).unwrap();
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
let tags: Vec<&[u8]> = (0..be(128)).map(|i| &icc[132 + 12 * i..136 + 12 * i]).collect();
for want in [b"desc", b"cprt", b"wtpt", b"A2B0", b"A2B1", b"A2B2", b"B2A0", b"B2A1", b"B2A2", b"gamt"] {
assert!(tags.contains(&&want[..]), "{name}: no {}", String::from_utf8_lossy(want));
}
let p = parse(&icc);
assert!(p.lut_a_to_b_saturation.is_some() && p.lut_b_to_a_saturation.is_some() && p.gamut.is_some(), "{name}");
}
}
+7 -1
View File
@@ -62,11 +62,14 @@ pub struct HitOptions {
/// (point type's baseline, area type's frame, type on a path's path), not anywhere in its
/// bounds.
pub type_path_only: bool,
/// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is
/// on or the guides are hidden.
pub skip_guides: bool,
}
impl Default for HitOptions {
fn default() -> Self {
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, scope: None }
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, scope: None }
}
}
@@ -318,6 +321,9 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
if !c.visible || editable_only && (c.locked || c.is_template()) {
return false;
}
if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) {
return false;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c)
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
+84 -4
View File
@@ -36,6 +36,7 @@ pub mod marks;
mod mem;
pub mod metadata;
pub mod node;
pub mod objects_on_path;
pub mod orient;
pub mod overprint;
pub mod pattern;
@@ -215,13 +216,33 @@ impl Unit {
/// The rulers' label step at `zoom` (screen pixels per point), in this unit: the first of
/// 1, 2, 5 × 10ⁿ that puts labels at least 50 pixels apart. Ticks mark every tenth of it.
pub fn ruler_step(self, zoom: f64) -> f64 {
const STEPS: [f64; 19] =
const DECIMAL: [f64; 19] =
[0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
STEPS.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
// An inch is divided in half, not in ten: a tenth of an inch is not a mark an inch ruler
// carries, so below an inch the steps are binary fractions (#2827). Labelling stops at a
// sixteenth; zooming in past that leaves the labels where they are and only the ticks
// between them grow denser, down to a hundred and twenty-eighth
// ([`Self::ruler_minor`]). A label is at most six characters that way ("0.0625"), which
// the left ruler has room for when it spells one down the ruler a character at a time.
// From an inch up the ladder is the shared one.
const BINARY: [f64; 15] = [1.0 / 16.0, 0.125, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 10000.0];
let steps: &[f64] = if self.ruler_halves() { &BINARY } else { &DECIMAL };
steps.iter().copied().find(|s| s * self.points() * zoom >= 50.0).unwrap_or(10000.0)
}
/// Does this unit's ruler divide a step in half rather than in ten? Inches do; the metric
/// units, points, pixels and picas are read in tenths.
fn ruler_halves(self) -> bool {
matches!(self, Unit::Inches)
}
/// The unlabelled ticks between two labels, `step` apart ([`Self::ruler_step`]): an eighth of
/// the step on an inch ruler, so every tick is a binary fraction of an inch and they stay about
/// as dense as the tenths elsewhere; a tenth of it otherwise.
pub fn ruler_minor(self, step: f64) -> f64 {
if self.ruler_halves() { step / 8.0 } else { step / 10.0 }
}
/// `v` (points) on the nearest ruler tick at `zoom` ([`Self::ruler_step`]).
pub fn snap_to_ruler_tick(self, v: f64, zoom: f64) -> f64 {
let tick = self.ruler_step(zoom) / 10.0 * self.points();
let tick = self.ruler_minor(self.ruler_step(zoom)) * self.points();
(v / tick).round() * tick
}
pub fn from_pt(self, v: f64) -> f64 {
@@ -274,7 +295,12 @@ impl Unit {
pub fn named(name: &str) -> Option<Unit> {
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
// is left to its label and key.
Unit::ALL
.into_iter()
.find(|u| letters(u.label()).eq(given()))
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
}
/// A point value as the canvas measurement labels show it: two decimals and the suffix
/// (`12.50 mm`).
@@ -801,6 +827,12 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
pub const FORMAT_VERSION: u32 = 1;
impl Document {
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
/// isn't a number).
pub fn raster_ppi(&self) -> f64 {
Some(self.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
}
/// The ruler zero point, or zero on both axes when either stored coordinate is invalid.
/// Rulers, coordinate fields and guide snapping use the same supported range.
pub fn ruler_origin_or_zero(&self) -> Point {
@@ -1549,9 +1581,17 @@ impl Document {
{
dimension.source = None;
}
let mut renamed = std::collections::HashMap::new();
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
}
// An Objects on Path group names its path and its objects by id: the copy names the copies.
if let Some(e) = n.extras_if_any()
&& let Some(on_path) = &mut e.objects_on_path
{
on_path.rename(&renamed);
}
n
}
@@ -1792,6 +1832,46 @@ mod tests {
use super::*;
use vectorcraft_geom::shapes;
/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch
/// are binary fractions now, and the minor ticks between them stay binary too; every other
/// unit is still read in tenths.
#[test]
fn an_inch_ruler_steps_in_binary_fractions() {
let step = |u: Unit, zoom: f64| u.ruler_step(zoom);
// Every inch step at or below an inch is 1/2^k, never a tenth or a fifth.
for zoom in [1.0, 2.0, 5.0, 7.5, 10.0, 20.0, 40.0, 100.0, 400.0] {
let s = step(Unit::Inches, zoom);
if s <= 1.0 {
let k = s.recip();
assert_eq!(k, k.round(), "{s} at zoom {zoom} is 1/k for a whole k");
assert!((k.round() as u64).is_power_of_two(), "{s} at zoom {zoom} is 1/2^k");
}
// A label step always leaves at least 50 px, which is what the ladder is picked for.
assert!(s * Unit::Inches.points() * zoom >= 50.0, "zoom {zoom}");
}
// The steps the report named: tenths became quarters, eighths and sixteenths.
assert_eq!(step(Unit::Inches, 5.0), 0.25);
assert_eq!(step(Unit::Inches, 10.0), 0.125);
assert_eq!(step(Unit::Inches, 20.0), 0.0625);
// An inch and wider is unchanged.
assert_eq!(step(Unit::Inches, 1.0), 1.0);
// Labelling stops at a sixteenth, as the report says Illustrator's does, so zooming in
// further only adds ticks. A label therefore never needs more than the six characters of
// "0.0625", which the left ruler has room for when it spells one out a character per line.
for zoom in [20.0, 50.0, 100.0, 400.0, 1600.0] {
assert_eq!(step(Unit::Inches, zoom), 0.0625, "zoom {zoom}");
}
// The unlabelled ticks go on down to a hundred and twenty-eighth: the 32nds, 64ths and
// 128ths the report expects to appear unlabelled when zoomed in close.
assert_eq!(Unit::Inches.ruler_minor(0.0625), 1.0 / 128.0);
// The minor ticks: an eighth of the step on an inch ruler, a tenth elsewhere.
assert_eq!(Unit::Inches.ruler_minor(0.25), 0.03125);
assert_eq!(Unit::Millimeters.ruler_minor(25.0), 2.5);
assert_eq!(Unit::Points.ruler_minor(100.0), 10.0);
// Other units keep the decimal ladder, so a tenth and a fifth still appear.
assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0));
}
fn doc_with_rects() -> (Document, NodeId, NodeId) {
let mut d = Document::new(612.0, 792.0);
let layer = d.layers[0].id;
+3
View File
@@ -253,6 +253,9 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
b += e.objects_on_path.as_deref().map_or(0, |o| {
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
});
}
b += match &n.kind {
NodeKind::Path { path, live, .. } => path_bytes(path) + boxed(live),
+59
View File
@@ -82,6 +82,31 @@ impl DocMetadata {
}
}
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
/// bytes twice (#2880).
///
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
/// encryption: neither may be pinned by the environment.
pub fn output_time() -> Option<i64> {
pinned_or(|n| std::env::var(n).ok(), now_unix)
}
/// [`output_time`] with the environment and the clock supplied, so a test can show that the
/// variable is preferred and that the clock answers whenever it does not.
fn pinned_or(get: impl Fn(&str) -> Option<String>, clock: impl Fn() -> Option<i64>) -> Option<i64> {
stamp_from_env(get).or_else(clock)
}
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
/// the clock decides — an export is never refused over the environment.
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
}
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
#[cfg(not(target_arch = "wasm32"))]
pub fn now_unix() -> Option<i64> {
@@ -148,4 +173,38 @@ mod tests {
);
assert!(DocMetadata::default().png_text("").is_empty());
}
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
#[test]
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
}
assert_eq!(stamp_from_env(env(None)), None, "unset");
// Another variable never stands in for it.
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
}
/// What a file records: the variable when it is usable, the clock whenever it is not. The
/// environment and the clock are passed in, so no test changes this process's environment --
/// that is `unsafe` under edition 2024 and would reach every other test running beside it.
#[test]
fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() {
let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned);
let clock = || Some(1_000_000);
assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins over the clock");
assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "including the epoch itself");
for ignored in [None, Some(""), Some("-1"), Some("later")] {
assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock");
}
// A platform with no clock and no variable has no date to record.
assert_eq!(pinned_or(env(None), || None), None);
assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock");
}
}
+16
View File
@@ -572,6 +572,9 @@ pub struct NodeExtras {
pub intertwine: Option<Box<PathData>>,
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
/// Object › Objects on Path: the group's path and the objects attached to it (see
/// [`crate::objects_on_path`]).
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -590,6 +593,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras {
dimension: None,
intertwine: None,
live_paint_gaps: None,
objects_on_path: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
@@ -724,6 +728,7 @@ impl Serialize for Node {
e.dimension.is_none(),
e.intertwine.is_none(),
e.live_paint_gaps.is_none(),
e.objects_on_path.is_none(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -762,6 +767,7 @@ impl Serialize for Node {
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
field!("live_paint_gaps", &e.live_paint_gaps);
field!("objects_on_path", &e.objects_on_path);
st.end()
}
}
@@ -821,6 +827,8 @@ struct NodeWire {
intertwine: Option<Box<PathData>>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
}
impl From<NodeWire> for Node {
@@ -840,6 +848,7 @@ impl From<NodeWire> for Node {
dimension: w.dimension,
intertwine: w.intertwine,
live_paint_gaps: w.live_paint_gaps,
objects_on_path: w.objects_on_path,
};
Node {
id: w.id,
@@ -1025,6 +1034,7 @@ impl Node {
match &self.kind {
NodeKind::Layer { .. } => "Layer",
NodeKind::Group { clip: true, .. } => "Clip Group",
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
NodeKind::Group { .. } => "Group",
NodeKind::Path { live: Some(l), .. } => l.label(),
NodeKind::Path { guide: true, .. } => "Guide",
@@ -1251,6 +1261,12 @@ impl Node {
{
dimension.transform(a);
}
// The frames of the objects attached to a path move with them.
if let Some(e) = self.extras_if_any()
&& let Some(on_path) = &mut e.objects_on_path
{
on_path.transform(a);
}
if !self.is_layer() {
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
}
+335
View File
@@ -0,0 +1,335 @@
//! Object › Objects on Path: objects attached to a path and spread along it.
//!
//! An Objects on Path group is an ordinary group ([`NodeKind::Group`]) with an [`ObjectsOnPath`]
//! record in its extras ([`crate::node::NodeExtras::objects_on_path`]). One child is the path, and
//! the other children are the attached objects, kept where they are drawn: the group draws, hits,
//! saves and exports as a group. The record lists the objects in their order along the path, each
//! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them
//! evenly along the path from its first anchor (the first object sits on that anchor, and the last
//! one sits on the end of an open path), puts the attachment point of each one
//! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there,
//! tilted by [`ObjectsOnPath::rotate`].
//!
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::{Affine, PathData, Point};
use crate::blend::Spine;
use crate::symbols::SymbolBoxes;
use crate::{Node, NodeId, NodeKind};
/// The center of the nine reference points.
pub const CENTER: u8 = 4;
fn center() -> u8 {
CENTER
}
/// What makes a group an Objects on Path group (see the module docs).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ObjectsOnPath {
/// The child of the group that is the path.
pub path: NodeId,
/// The attached objects, in their order along the path.
#[serde(default)]
pub members: Vec<Member>,
/// Pivot: the reference point of each object that attaches to the path, 0 to 8, row by row
/// from the top left of the object's bounds in its frame (4: the center).
#[serde(default = "center")]
pub pivot: u8,
/// Rotate: the counter-clockwise degrees each object is turned from upright on the path. An
/// upright object has its frame's x axis along the path's direction at its attachment point.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub rotate: f64,
}
/// An object attached to the path of an Objects on Path group.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "MemberWire")]
pub struct Member {
pub id: NodeId,
/// The object's frame, mapped to the document: the object's art is drawn in this frame.
pub frame: Affine,
/// The angle the frame is turned by (radians, the document's axes): the path's direction at
/// the object's attachment point plus the tilt from Rotate when it was placed, or the angle of
/// the frame's x axis before then.
pub angle: f64,
}
/// A [`Member`] as read from a file: without `angle`, the angle of the frame's x axis.
#[derive(Deserialize)]
struct MemberWire {
id: NodeId,
frame: Affine,
#[serde(default)]
angle: Option<f64>,
}
impl From<MemberWire> for Member {
fn from(w: MemberWire) -> Self {
let angle = w.angle.filter(|a| a.is_finite()).unwrap_or_else(|| x_angle(w.frame));
Self { id: w.id, frame: w.frame, angle }
}
}
/// The angle of `frame`'s x axis (radians, the document's axes).
fn x_angle(frame: Affine) -> f64 {
let [a0, a1, ..] = frame.as_coeffs();
a1.atan2(a0)
}
impl Member {
/// `n` in its frame as drawn ([`upright_frame`]).
pub fn new(n: &Node) -> Self {
let frame = upright_frame(n);
Self { id: n.id, frame, angle: x_angle(frame) }
}
}
impl ObjectsOnPath {
/// A record for the objects `members` (in their order along the path) attached to `path`, each
/// in its frame as drawn ([`upright_frame`]).
pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self {
let members = members.iter().map(|n| Member::new(n)).collect();
Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } }
}
/// The frames go with the group, and each frame's angle becomes the transformed path's
/// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear
/// and reflection when it spreads the objects along the transformed path again. A reflection
/// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path.
pub fn transform(&mut self, a: Affine) {
let before = tilt(self.rotate);
if a.determinant() < 0.0 && self.rotate != 0.0 {
self.rotate = -self.rotate;
}
let after = tilt(self.rotate);
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
for m in &mut self.members {
m.frame = a * m.frame;
let (sin, cos) = (m.angle - before).sin_cos();
let (x, y) = (a0 * cos + a2 * sin, a1 * cos + a3 * sin);
if x.is_finite() && y.is_finite() && (x != 0.0 || y != 0.0) {
m.angle = y.atan2(x) + after;
}
}
}
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
self.path = to(self.path);
for m in &mut self.members {
m.id = to(m.id);
}
}
/// The group's children changed from `before` to `after`: a new child at the index of a member
/// that is gone replaces it (Break Link to Symbol, for one), keeping its place on the path.
pub fn follow_replacements(&mut self, before: &[Arc<Node>], after: &[Arc<Node>]) {
let now: HashSet<NodeId> = after.iter().map(|c| c.id).collect();
let then: HashSet<NodeId> = before.iter().map(|c| c.id).collect();
let listed: HashSet<NodeId> = self.members.iter().map(|m| m.id).collect();
let mut ids = HashMap::new();
for (old, new) in before.iter().zip(after) {
if listed.contains(&old.id) && !now.contains(&old.id) && !then.contains(&new.id) && new.id != self.path {
ids.insert(old.id, new.id);
}
}
if !ids.is_empty() {
self.rename(&ids);
}
}
}
/// Is `n` an Objects on Path group?
pub fn is_objects_on_path(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. }) && n.extras().objects_on_path.is_some()
}
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
/// (not a guide) whose first subpath has some length.
pub fn attachable_path(n: &Node) -> Option<&PathData> {
match &n.kind {
NodeKind::Path { path, guide: false, .. } if Spine::new(path).is_some_and(|s| s.length() > 1e-9) => Some(path),
_ => None,
}
}
/// The frame `n` is drawn in when it is attached: the page's axes turned by its rotation
/// ([`Node::bbox_angle`]) less the nearest multiple of 90°, about the center of its bounds. Placed in
/// that frame, an object stands square to the path or across it.
pub fn upright_frame(n: &Node) -> Affine {
let a = n.bbox_angle;
let off = if a.is_finite() { a - (a / 90.0).round() * 90.0 } else { 0.0 };
match n.geometric_bounds() {
Some(b) if off.abs() > 1e-9 => Affine::rotate_about(-off.to_radians(), b.center()),
_ => Affine::IDENTITY,
}
}
/// Where `n` objects attach along `path` (its first subpath): the point and the direction there
/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the
/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way
/// around. `None` for a path without length.
pub fn stops(path: &PathData, n: usize) -> Option<Vec<(Point, f64)>> {
let spine = Spine::new(path)?;
let len = spine.length();
if !len.is_finite() || len <= 1e-9 {
return None;
}
let closed = path.subpaths.first().is_some_and(|s| s.closed);
let gaps = if closed { n } else { n.saturating_sub(1) }.max(1);
Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect())
}
/// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees
/// counter-clockwise on the page.
fn tilt(rotate: f64) -> f64 {
-rotate.to_radians()
}
/// The frame that puts the attachment point `pivot` of `n` (drawn in `m`'s frame) at `at`, turned
/// by `angle` (radians, the document's axes) instead of `m`'s angle: the frame's own scale, shear
/// and reflection are kept. Symbol instances are measured by their art (`symbols`).
fn placed_frame(n: &Node, symbols: &SymbolBoxes, m: &Member, pivot: u8, at: Point, angle: f64) -> Option<Affine> {
let det = m.frame.determinant();
if !det.is_finite() || det.abs() < 1e-12 || !at.x.is_finite() || !at.y.is_finite() || !angle.is_finite() || !m.angle.is_finite() {
return None;
}
let mut local = n.clone();
local.transform(m.frame.inverse(), false);
let q = vectorcraft_geom::reference_point(local.geometric_bounds_with(symbols)?, usize::from(pivot.min(8)));
let [a0, a1, a2, a3, _, _] = m.frame.as_coeffs();
let shape = Affine::rotate(-m.angle) * Affine::new([a0, a1, a2, a3, 0.0, 0.0]);
Some(Affine::translate(at.to_vec2()) * Affine::rotate(angle) * shape * Affine::translate(-q.to_vec2()))
}
/// Is `a` the identity, give or take rounding?
fn negligible(a: Affine) -> bool {
let [a0, a1, a2, a3, a4, a5] = a.as_coeffs();
(a0 - 1.0).abs() < 1e-9 && a1.abs() < 1e-9 && a2.abs() < 1e-9 && (a3 - 1.0).abs() < 1e-9 && a4.abs() < 1e-6 && a5.abs() < 1e-6
}
/// Put the objects of Objects on Path group `g` on its path (see the module docs). Before that, the
/// record lets go of the objects that are no longer in the group, and children it does not list
/// (other than the path) join it at the end of the path. A group whose path is gone, or is no
/// longer a path object, becomes a plain group. Symbol instances are measured by their art
/// (`symbols`, from the document the group is in). Returns whether `g` changed.
pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
let Some(mut rec) = g.extras().objects_on_path.as_deref().cloned() else { return false };
let old = rec.clone();
let NodeKind::Group { children, .. } = &mut g.kind else { return false };
let path_id = rec.path;
let Some(path) = children.iter().find(|c| c.id == path_id).filter(|c| matches!(c.kind, NodeKind::Path { .. })) else {
g.extras_mut().objects_on_path = None;
return true;
};
let path = attachable_path(path).cloned();
let at: HashMap<NodeId, usize> = children.iter().enumerate().map(|(i, c)| (c.id, i)).collect();
rec.members.retain(|m| m.id != path_id && at.contains_key(&m.id));
let mut listed: HashSet<NodeId> = rec.members.iter().map(|m| m.id).collect();
for c in children.iter() {
if c.id != path_id && listed.insert(c.id) {
rec.members.push(Member::new(c));
}
}
let mut moved = false;
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) {
let turn = tilt(rec.rotate);
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
let Some(next) = placed_frame(c, symbols, m, rec.pivot, p, dir + turn) else { continue };
let delta = next * m.frame.inverse();
if !negligible(delta) {
Arc::make_mut(c).transform(delta, false);
*m = Member { id: m.id, frame: next, angle: dir + turn };
moved = true;
}
}
}
if rec != old {
g.extras_mut().objects_on_path = Some(Box::new(rec));
return true;
}
moved
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_geom::{Rect, shapes};
fn rect(id: u64, r: Rect) -> Node {
Node::path(NodeId(id), shapes::rectangle(r), Default::default())
}
#[test]
fn stops_spread_from_the_first_anchor() {
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
let s = stops(&open, 4).unwrap();
let xs: Vec<f64> = s.iter().map(|(p, _)| p.x.round()).collect();
assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path");
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let s = stops(&closed, 4).unwrap();
let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect();
pts.sort();
assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round");
assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none());
}
#[test]
fn upright_frames_round_the_rotation_to_a_right_angle() {
let mut n = rect(1, Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!(upright_frame(&n), Affine::IDENTITY);
n.bbox_angle = 90.0;
assert_eq!(upright_frame(&n), Affine::IDENTITY);
n.bbox_angle = 30.0;
let f = upright_frame(&n);
let [a0, a1, ..] = f.as_coeffs();
assert!((a1.atan2(a0).to_degrees() + 30.0).abs() < 1e-9, "a frame turned 30° counter-clockwise");
}
#[test]
fn a_symbol_instance_is_placed_by_its_art() {
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
// around it that instances are measured by without the symbols.
let mut d = crate::Document::new(400.0, 400.0);
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
let members = [&instance, &other];
let mut g = Node::new(
NodeId(4),
NodeKind::Group { children: vec![Arc::new(line), Arc::new(instance.clone()), Arc::new(other.clone())], clip: false },
);
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &members, 7, 0.0)));
let symbols = d.symbol_boxes();
assert!(place(&mut g, &symbols));
let placed = g.children().and_then(|c| c.iter().find(|c| c.id == NodeId(2)).cloned()).unwrap();
let b = placed.geometric_bounds_with(&symbols).unwrap();
assert!((b.y1 - 300.0).abs() < 1e-9 && (b.center().x - 100.0).abs() < 1e-9, "the art's bottom middle on the start: {b:?}");
}
#[test]
fn a_member_saved_without_its_angle_reads_the_angle_of_its_frame() {
let m = Member { id: NodeId(2), frame: Affine::rotate(0.5), angle: 0.5 };
let mut v = serde_json::to_value(m).unwrap();
assert_eq!(serde_json::from_value::<Member>(v.clone()).unwrap(), m);
v.as_object_mut().unwrap().remove("angle");
let back: Member = serde_json::from_value(v).unwrap();
assert!((back.angle - 0.5).abs() < 1e-12, "{back:?}");
}
#[test]
fn a_group_without_its_path_is_a_plain_group() {
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
let mut g = Node::new(NodeId(3), NodeKind::Group { children: vec![Arc::new(a.clone())], clip: false });
g.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0)));
let d = crate::Document::new(100.0, 100.0);
assert!(place(&mut g, &d.symbol_boxes()));
assert!(!is_objects_on_path(&g));
}
}
+7
View File
@@ -98,6 +98,13 @@ fn units_are_named_by_label_key_or_suffix() {
assert_eq!(Unit::named(""), None);
}
#[test]
fn a_shared_suffix_names_the_plain_unit() {
assert_eq!(Unit::named(Unit::Feet.suffix()), Some(Unit::Feet));
assert_eq!(Unit::named("FT"), Some(Unit::Feet));
assert_eq!(Unit::named(Unit::FeetInches.label()), Some(Unit::FeetInches));
}
#[test]
fn readouts_show_two_decimals() {
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
+39
View File
@@ -594,6 +594,45 @@ pub fn blur_plane(a: &mut Vec<f32>, w: usize, h: usize, sigma: f64) {
}
}
/// Grow a `w` × `h` plane by `radius` pixels each way, the box of SVG's feMorphology: each value
/// becomes the largest within the radius. A fractional radius blends the two whole radii around
/// it, so a silhouette grows smoothly as the raster scales. Does nothing for a radius of zero or
/// less, or when the sizes don't match.
pub fn dilate_plane(a: &mut Vec<f32>, w: usize, h: usize, radius: f64) {
if radius.is_nan() || radius <= 0.0 || w == 0 || h == 0 || w.checked_mul(h) != Some(a.len()) {
return;
}
let radius = radius.min(MAX_BOX as f64);
let whole = radius.floor() as usize;
let frac = (radius - whole as f64) as f32;
let mut tmp = vec![0.0; a.len()];
for (len, other) in [(w, h), (h, w)] {
max_plane(a, &mut tmp, len, whole, frac);
std::mem::swap(a, &mut tmp);
transpose(a, &mut tmp, len, other);
std::mem::swap(a, &mut tmp);
}
}
/// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest
/// value within `whole`, blended by `frac` towards the largest within `whole + 1`.
fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) {
for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) {
for (x, o) in out.iter_mut().enumerate() {
let lo = x.saturating_sub(whole + 1);
let hi = (x + whole + 1).min(w - 1);
let (mut inner, mut ring) = (row[x], row[x]);
for (j, v) in row[lo..=hi].iter().enumerate() {
if (lo + j).abs_diff(x) <= whole {
inner = inner.max(*v);
}
ring = ring.max(*v);
}
*o = inner + (ring - inner) * frac;
}
}
}
/// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones.
pub(super) type Px16 = [u16; 4];
+20
View File
@@ -41,6 +41,26 @@ fn fx(id: &str, p: serde_json::Value) -> PixelFx {
}
}
#[test]
fn dilate_plane_grows_by_its_radius_and_blends_a_fraction() {
// GUARDS: an Outer Glow grows its silhouette by a quarter raster pixel before the blur; a step in
// that growth as the raster scales would show as the glow's width jumping with the zoom.
let mut dot = vec![0.0f32; 7 * 7];
dot[3 * 7 + 3] = 255.0;
let mut grown = dot.clone();
dilate_plane(&mut grown, 7, 7, 1.0);
assert_eq!(grown[2 * 7 + 2], 255.0);
assert_eq!(grown[7 + 3], 0.0);
let mut half = dot.clone();
dilate_plane(&mut half, 7, 7, 1.5);
assert_eq!(half[3 * 7 + 4], 255.0);
assert_eq!(half[3 * 7 + 5], 127.5);
assert_eq!(half[3 * 7 + 6], 0.0);
let mut same = dot.clone();
dilate_plane(&mut same, 7, 7, 0.0);
assert_eq!(same, dot);
}
fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
+6
View File
@@ -29,6 +29,8 @@ pub enum RasterFx {
/// instead of `color`; `None` when it is `color`.
darkness: Option<f32>,
},
/// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid,
/// as the glows AI files export measure.
OuterGlow {
mode: BlendMode,
opacity: f32,
@@ -93,6 +95,10 @@ impl RasterFx {
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
match self {
RasterFx::Rasterize(r) => r.image(bounds, line),
RasterFx::OuterGlow { .. } => {
let o = self.outset(bounds) + 0.25 * line;
bounds.inflate(o, o)
}
fx => {
let o = fx.outset(bounds);
bounds.inflate(o, o)
+72 -8
View File
@@ -212,8 +212,12 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
}
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
/// otherwise each solid is a surface of its own, in that order.
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
let b = path.bounds()?;
let p = crate::merged_params(&fx.id, &fx.params);
@@ -229,12 +233,72 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
}
return Some(out);
}
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
let (paint, opacity, blend) = paint_of(item);
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
// failing the frame; commands validate the same evaluator and report its errors.
let f = facets(&fx.id, path, rule, b, &p).ok()?;
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))])
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items
.iter()
.enumerate()
.filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_)))
.map(|(_, i)| {
let (area, rule) = region(path, rule, i);
(*i, area, rule)
})
.collect();
// The solids share the bounds of all their shapes: one center to turn, twist and taper about,
// and Inflate's grid reaches over the strokes' outlines.
let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
let built = if fx.id == INFLATE {
// One eye for every puff, as far away as the highest needs.
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect();
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
} else {
parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
};
let mut solids = vec![];
for ((item, _, _), f) in parts.iter().zip(built) {
let item: &AppearanceItem = item;
match f {
Ok(f) => solids.push((item, f)),
// A stroke's outline past the limits is left out; the fill's solid still draws.
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
// A loaded file or a shape edited past the limits keeps drawing its source path
// instead of failing the frame; commands validate the same evaluator and report its
// errors.
Err(_) => return None,
}
}
if solids.is_empty() {
return None;
}
let opaque = solids.iter().all(|(item, _)| {
let (_, opacity, blend) = paint_of(item);
opacity >= 1.0 && blend == BlendMode::Normal
});
let surfaces: Vec<(Vec<f64>, Node)> = solids
.into_iter()
.map(|(item, f)| {
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
let (paint, opacity, blend) = paint_of(item);
(depths, surface(n, f, &paint, opacity, blend, colored))
})
.collect();
// One order takes a plain group of faces from each solid, one face for each facet. With
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
// opacity mask, and then it stays whole.
let plain = surfaces.iter().all(|(depths, s)| {
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
});
if !opaque || !plain {
return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect());
}
let mut faces = vec![];
for (depths, s) in surfaces {
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children));
}
}
// A stable sort: faces at the same depth keep the order of the appearance.
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).collect()))])
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
+228 -7
View File
@@ -1,6 +1,6 @@
use serde_json::json;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::{Affine, BezPath};
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
use super::*;
@@ -44,7 +44,8 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
assert_eq!(n.path_data(), Some(&source), "{id}");
assert!(!faces(&art).is_empty(), "{id}");
// The faces are the fill's red, shaded: never brighter, never another hue.
// The faces are the fill's red or the stroke's black, shaded: never brighter, never another
// hue.
for f in faces(&art) {
let [r, g, b] = fill_rgb(f);
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
@@ -76,12 +77,229 @@ fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
// Extrude & Bevel paints one surface with the top fill.
// Extrude & Bevel draws the fill's solid and the stroke's, both opaque, as one surface.
let mut n = square_node();
n.appearance.effects.push(fx(EXTRUDE, json!({})));
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
}
const BLUE: Color = Color::Rgb { r: 0.1, g: 0.3, b: 0.9 };
/// `b` with a red fill and a 6 pt blue stroke above it, on the path.
fn stroked(b: &BezPath) -> Node {
Node::path(NodeId(1), PathData::from_bezpath(b), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 6.0))
}
/// A `width` × 100 pt rectangle at (100, 100), stroked.
fn stroked_rect(width: f64) -> Node {
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((100.0 + width, 100.0));
b.line_to((100.0 + width, 200.0));
b.line_to((100.0, 200.0));
b.close_path();
stroked(&b)
}
/// Is `face` in the stroke's blue (rather than the fill's red)?
fn blue(face: &Node) -> bool {
let [r, _, b] = fill_rgb(face);
b > r
}
/// The bounds of the faces' own paths (without their seam strokes).
fn extent(faces: &[&Node]) -> Rect {
faces.iter().filter_map(|f| f.path_data()?.bounds()).reduce(|a, b| a.union(b)).unwrap()
}
fn assert_rect(r: Rect, x0: f64, y0: f64, x1: f64, y1: f64) {
assert!([r.x0 - x0, r.y0 - y0, r.x1 - x1, r.y1 - y1].iter().all(|d| d.abs() < 1e-6), "{r:?}");
}
/// Extrude & Bevel and Inflate build a solid from each stroke's outline as well as from the path
/// for the fill: seen straight on, the stroke's faces reach 3 pt (half its width) outside the
/// fill's, and they are drawn in the stroke's paint.
#[test]
fn a_stroke_builds_a_solid_from_its_outline_beside_the_fill() {
for id in [EXTRUDE, INFLATE] {
let mut n = stroked_rect(100.0);
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})));
let art = solid_art(&n).unwrap();
let (strokes, fills): (Vec<&Node>, Vec<&Node>) = faces(&art).into_iter().partition(|f| blue(f));
assert!(!strokes.is_empty(), "{id}: the stroke draws");
assert_rect(extent(&fills), 100.0, 100.0, 200.0, 200.0);
assert_rect(extent(&strokes), 97.0, 97.0, 203.0, 203.0);
// Its own blue, shaded: never brighter, never another hue.
for f in &strokes {
let [r, g, b] = fill_rgb(f);
assert!(b <= 0.9 + 1e-6 && (r * 9.0 - b).abs() < 1e-4 && (g * 3.0 - b).abs() < 1e-4, "{id}: {r} {g} {b}");
}
}
}
/// Turned, the extrusion's walls show in both paints, and the front caps, which nothing of either
/// solid can hide, are drawn last: the fill's, then the stroke's above it, equally lit. Without
/// depth the two flat shapes are drawn in the same order. A stroke below the fill has its cap
/// drawn below the fill's.
#[test]
fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() {
// A triangle wider than deep, so the near ends of the walls lie nearer than the middle of the
// caps; the mean of its outline's points is not the mean of the stroke's.
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((400.0, 100.0));
b.line_to((100.0, 200.0));
b.close_path();
for params in [json!({}), json!({"depth": 120, "bevel": "rounded"}), json!({"depth": 0})] {
let mut n = stroked(&b);
n.appearance.effects.push(fx(EXTRUDE, params.clone()));
n.appearance.items.reverse();
let art = solid_art(&n).unwrap();
let [.., stroke_cap, fill_cap] = faces(&art)[..] else { panic!("{params}: two caps") };
assert!(blue(stroke_cap) && !blue(fill_cap), "{params}: the stroke below");
n.appearance.items.reverse();
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let flat = params["depth"] == json!(0);
assert!(if flat { faces.len() == 2 } else { faces.len() > 4 }, "{params}: {} faces", faces.len());
let [.., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{params}: two caps") };
assert!(!blue(fill_cap) && blue(stroke_cap), "{params}");
// The stroke's cap is a ring: its outline and the hole the fill shows through.
assert_eq!(stroke_cap.path_data().unwrap().subpaths.len(), 2, "{params}");
assert_eq!(fill_cap.path_data().unwrap().subpaths.len(), 1, "{params}");
let shade = |f: &Node, i: usize, of: f32| fill_rgb(f)[i] / of;
assert!((shade(fill_cap, 0, 0.8) - shade(stroke_cap, 2, 0.9)).abs() < 1e-5, "{params}: one plane, one light");
if !flat {
assert!(faces.iter().filter(|f| blue(f)).count() > 2 && faces.iter().filter(|f| !blue(f)).count() > 1, "{params}: walls of both");
}
}
}
/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose
/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner.
#[test]
fn a_stroked_open_path_builds_a_ribbon() {
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((200.0, 100.0));
b.line_to((200.0, 200.0));
for id in [EXTRUDE, INFLATE] {
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::None, Paint::solid(BLUE), 6.0));
n.appearance.effects.push(fx(id, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "volume": 0})));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
assert!(faces.iter().all(|f| blue(f)), "{id}");
assert_rect(extent(&faces), 100.0, 97.0, 203.0, 200.0);
let area: f64 = faces.iter().map(|f| f.path_data().unwrap().to_bezpath().area().abs()).sum();
// 6 × 200 less the 3 × 3 the legs share, plus the 3 × 3 miter.
assert!((area - 1200.0).abs() < 1e-6, "{id}: {area}");
}
}
/// A stroke whose outline takes more points than a solid takes is left out, and the fill's solid
/// still draws.
#[test]
fn a_stroke_past_the_limits_is_left_out() {
// 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline.
let points: Vec<Point> = (0..1500)
.map(|k| {
let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos();
let r = if k % 2 == 0 { 100.0 } else { 96.0 };
Point::new(200.0 + r * c, 200.0 + r * s)
})
.collect();
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
for id in [EXTRUDE, INFLATE] {
let mut n = Node::path(NodeId(1), path.clone(), Appearance::basic(Paint::solid(RED), Paint::solid(BLUE), 2.0));
n.appearance.effects.push(fx(id, json!({})));
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}: the fill's solid draws"));
assert!(!faces(&art).is_empty() && faces(&art).iter().all(|f| !blue(f)), "{id}");
}
}
/// At no volume Inflate's front is flat: turned, the fill's cells are all drawn before the stroke's,
/// whatever their depths, as the extrusion's caps are. When the fill puffs, a stroke too narrow to
/// puff on the grid stays flat and keeps its depths: the fill's puff is drawn over its far side.
#[test]
fn a_flat_inflate_draws_the_strokes_front_above_the_fills() {
// On the 100 pt square's grid (cells 101 / 48 pt apart from 99.5) no grid point falls inside a
// 1 pt stroke, so the stroke stays flat.
for (volume, width, length) in [(0.0, 6.0, 300.0), (50.0, 1.0, 100.0)] {
let mut n = stroked_rect(length);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.width = width;
}
n.appearance.effects.push(fx(INFLATE, json!({ "volume": volume })));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let first_blue = faces.iter().position(|f| blue(f)).unwrap();
let last_red = faces.iter().rposition(|f| !blue(f)).unwrap();
assert_eq!(last_red < first_blue, volume == 0.0, "{volume}: red at {last_red}, blue from {first_blue} of {}", faces.len());
}
}
/// With Perspective one eye sees the fill's puff and the stroke's, however much higher one rises:
/// seen from behind, the back of a stroke aligned outside meets the fill's back exactly.
#[test]
fn inflate_sees_the_fill_and_the_stroke_from_one_eye() {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.align = vectorcraft_doc::StrokeAlign::Outside;
}
let params = json!({"volume": 100, "depth": 100, "perspectiveAngle": 120, "rotationX": 15, "rotationY": 160, "rotationZ": 0});
n.appearance.effects.push(fx(INFLATE, params));
let art = solid_art(&n).unwrap();
let [.., fill_back, stroke_back] = faces(&art)[..] else { panic!("two backs") };
assert!(!blue(fill_back) && blue(stroke_back));
let fill = fill_back.path_data().unwrap().bounds().unwrap();
// The ring's inner contour: the smaller of its two.
let rims = stroke_back.path_data().unwrap().subpaths.iter().filter_map(|s| PathData::new(vec![s.clone()]).bounds());
let hole = rims.reduce(|a, b| a.intersect(b)).unwrap();
assert_rect(hole, fill.x0, fill.y0, fill.x1, fill.y1);
}
/// A stroke with its own transparency or blending is a surface of its own above the fill's, so its
/// opacity applies to the stroke as a whole.
#[test]
fn a_translucent_stroke_is_a_surface_of_its_own() {
for (opacity, blend) in [(0.5, BlendMode::Normal), (1.0, BlendMode::Multiply)] {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.opacity = opacity;
s.blend = blend;
}
n.appearance.effects.push(fx(EXTRUDE, json!({})));
let art = solid_art(&n).unwrap();
let surfaces = art.children().unwrap();
assert_eq!(surfaces.len(), 2);
assert_eq!((surfaces[0].opacity, surfaces[0].blend), (1.0, BlendMode::Normal));
assert_eq!((surfaces[1].opacity, surfaces[1].blend), (opacity, blend));
assert!(surfaces[0].children().unwrap().iter().all(|f| !blue(f)) && surfaces[1].children().unwrap().iter().all(|f| blue(f)));
}
}
/// With a light that is not white, a fill in a gradient with transparent stops is drawn with an
/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their
/// own, and the mask stays on the fill's. Under a white light the same object is one surface.
#[test]
fn a_masked_surface_under_a_colored_light_stays_whole() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut half = GradientStop::new(1.0, RED);
half.opacity = 0.5;
let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] });
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
f.paint = Paint::Gradient(Box::new(gradient.clone()));
}
n.appearance.effects.push(fx(EXTRUDE, lights.clone()));
let art = solid_art(&n).unwrap();
let drawn = art.children().unwrap();
assert_eq!(drawn.len(), surfaces, "{lights}");
assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}");
}
}
#[test]
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
let mut n = square_node();
@@ -230,9 +448,9 @@ fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
let mut n = square_node();
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspectiveAngle": 0})));
// Straight on, the extrusion shows its front: the offset square.
// Straight on, the extrusion shows its front: the offset square and its 4 pt stroke.
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
assert!((b.width() - 144.0 - 0.75).abs() < 1e-6, "{b:?}");
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
let art = solid_art(&n).unwrap();
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
@@ -316,6 +534,9 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
}
let (groups, clips, paths) = count(&art);
assert!(paths > 1000, "exercise a tessellated surface");
assert_eq!(clips, 1);
assert_eq!(groups, 7, "group count must not grow with triangle count");
// The fill's solid and the stroke's, each one composite with its own clip.
let surfaces = art.children().unwrap().len();
assert_eq!(surfaces, 2);
assert_eq!(clips, surfaces);
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
}
+124 -12
View File
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: it is not, and the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it",
has_doc,
freehand
),
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Anchor Point",
[],
None,
"{id, subpath, anchor} remove one anchor, re-fitting the curve",
"{id, subpath?, anchor} remove one anchor, re-fitting the curve",
has_doc,
remove_anchor
),
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Anchor Point",
[],
None,
"{id, subpath, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
"{id, subpath?, anchor, to: \"corner\"|\"smooth\", x?, y?} (smooth with x/y: out handle there, in handle mirrored; without: as the Curvature tool curves a point, along the circle through it and its neighbours, or at an open end one handle into the curve)",
has_doc,
convert_anchor
),
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape Segment",
[],
None,
"{id, subpath, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
"{id, subpath?, segment, t, dx, dy, perpendicular?: bool} bend a segment so the point at t moves by (dx, dy); with perpendicular (Shift), both handles stand at right angles to the line between its anchors at one length, and the point at t lies as far from that line as the moved point does",
has_doc,
reshape_segment
),
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Split Path",
[],
None,
"{id, subpath, segment, t} or {id, subpath, anchor}: closed paths open there, open paths become two → {ids}",
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
has_doc,
split
),
@@ -215,6 +215,15 @@ fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
/// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other
/// value is [`usize_req`]'s error.
pub(super) fn usize_opt(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(_) => usize_req(p, key, cmd).map(Some),
}
}
fn path_mut(d: &mut Document, id: NodeId) -> Result<&mut PathData> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
match &mut n.kind {
@@ -548,6 +557,87 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
}
/// Pencil's Edit Selected Paths in the middle of a path: a stroke from one point of a subpath of
/// `pd` to another, each within `within` of it, replaces the section between them (the shorter
/// way round a closed subpath) with the stroke fitted at `tol`. The subpath's index and its new
/// shape; None when the stroke doesn't start and end on the same subpath, or meets it once.
fn redraw_section(pd: &PathData, pts: &[Point], tol: f64, within: f64) -> Option<(usize, SubPath)> {
use kurbo::ParamCurveArclen;
let (first, last) = (*pts.first()?, *pts.last()?);
let (si, _, _, _, d_a) = pd.nearest(first)?;
let (sj, _, _, _, d_b) = pd.nearest(last)?;
if si != sj || d_a > within || d_b > within || pts.len() < 2 {
return None;
}
let mut sp = pd.subpaths.get(si)?.clone();
if sp.anchors.len() < 2 {
return None;
}
// The anchor nearest to `q`: the one there, else a new one, and whether it was added. Each
// point is looked up on the subpath as it is by then: splitting a straight segment makes two
// new ones, which a `t` along the old one doesn't measure.
let place = |sp: &mut SubPath, q: Point| {
let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q)?;
let n = sp.anchors.len();
Some(if t <= 1e-6 {
(seg % n, false)
} else if t >= 1.0 - 1e-6 {
((seg + 1) % n, false)
} else {
(sp.insert_anchor(seg, t), true)
})
};
let (mut from, _) = place(&mut sp, first)?;
let (to, added) = place(&mut sp, last)?;
if added && to <= from {
from += 1;
}
if from == to {
return None;
}
let n = sp.anchors.len();
// The length of the run of segments from anchor i forward to anchor j.
let run = |i: usize, j: usize| (0..(j + n - i) % n).map(|k| sp.segment((i + k) % n).arclen(0.01)).sum::<f64>();
// The stroke goes from `start` to `end` and replaces the run between them; `kept` runs from
// `end` round to `start`, then the stroke's inner anchors follow.
let (start, end, reversed) = if sp.closed {
if run(from, to) <= run(to, from) { (from, to, false) } else { (to, from, true) }
} else if from < to {
(from, to, false)
} else {
(to, from, true)
};
let mut stroke = pts.to_vec();
if reversed {
stroke.reverse();
}
let (Some(s0), Some(s1)) = (stroke.first_mut(), sp.anchors.get(start)) else { return None };
*s0 = s1.p;
let (Some(e0), Some(e1)) = (stroke.last_mut(), sp.anchors.get(end)) else { return None };
*e0 = e1.p;
let fit = fit_freehand(&stroke, tol, false).anchors;
let (Some(f0), Some(f1)) = (fit.first().copied(), fit.last().copied()) else { return None };
let inner = fit.get(1..fit.len().saturating_sub(1)).unwrap_or_default();
let (before, after): (Vec<Anchor>, Vec<Anchor>) = if sp.closed {
((0..=(start + n - end) % n).filter_map(|k| sp.anchors.get((end + k) % n).copied()).collect(), vec![])
} else {
(sp.anchors.get(..=start)?.to_vec(), sp.anchors.get(end..)?.to_vec())
};
let mut anchors = before;
if let Some(a) = anchors.last_mut() {
*a = Anchor::with_handles(a.p, a.h_in, f0.h_out);
}
anchors.extend_from_slice(inner);
let at_end = anchors.len();
anchors.extend(after);
// The anchor the stroke ends on: the first after it, or round to the first of a closed one.
let end_at = if sp.closed { 0 } else { at_end };
if let Some(a) = anchors.get_mut(end_at) {
*a = Anchor::with_handles(a.p, f1.h_in, a.h_out);
}
Some((si, SubPath::new(anchors, sp.closed)))
}
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.freehand";
let samples = samples_param(p, "points", C)?;
@@ -630,6 +720,28 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
})?;
return Ok(json!({ "id": id.0 }));
}
if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) {
let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?;
let within = f64_or(rd, "within", 12.0);
if !within.is_finite() || within < 0.0 {
return Err(bad(C, "redraw within must be a finite distance of 0 or more"));
}
let (pd, _) = path_of(&s.doc()?.doc, id)?;
// shortcut: a stroke pressure profile is left as it was, measured along the old path; fit
// it to the new length if Calligraphic pressure strokes get redrawn.
if let Some((si, sp)) = redraw_section(&pd, &pts, tol, within) {
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
*path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| bad(C, "no such subpath"))? = sp;
if keep_selected {
sel.set([id]);
} else {
sel.remove(id);
}
Ok(())
})?;
return Ok(json!({ "id": id.0 }));
}
}
if pts.len() < 2 {
return Err(bad(C, "need at least two points"));
}
@@ -692,7 +804,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move};
const C: &str = "path.curvatureEdit";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)),
(None, None) => None,
@@ -756,7 +868,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.removeAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let removed = s.edit("Delete Anchor Point", |d, sel| {
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
@@ -863,7 +975,7 @@ fn remove_anchors_of(
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.convertAnchor";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = usize_req(p, "anchor", C)?;
let smooth = str_param(p, "to") == Some("smooth");
let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
@@ -898,7 +1010,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.reshapeSegment";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let seg = usize_req(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
@@ -923,9 +1035,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
fn split(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.split";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let anchor = p.get("anchor").and_then(Value::as_u64).map(|v| v as usize);
let seg = p.get("segment").and_then(Value::as_u64).map(|v| v as usize);
let si = usize_opt(p, "subpath", C)?.unwrap_or(0);
let anchor = usize_opt(p, "anchor", C)?;
let seg = usize_opt(p, "segment", C)?;
if anchor.is_none() && seg.is_none() {
return Err(bad(C, "need `anchor` or `segment` + `t`"));
}
+1 -1
View File
@@ -278,7 +278,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
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 created = vectorcraft_doc::metadata::output_time();
let mut renderer = vectorcraft_render::Renderer::new();
for (board, region, origin) in pages {
let rect = vectorcraft_eps::preview_rect(region, origin);
+1 -1
View File
@@ -110,7 +110,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
if boards.len() > 1 {
enc.warnings.push(SEPARATE.to_string());
}
let created = vectorcraft_doc::metadata::now_unix();
let created = vectorcraft_doc::metadata::output_time();
let failed = vectorcraft_render::decode_failures();
for b in boards {
let one = super::save::artboard_doc(doc, b);
+31 -4
View File
@@ -194,6 +194,12 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(D
)
}
/// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one?
fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool {
let Some(range) = &opts.pages else { return true };
vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len()))
}
/// [`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> {
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
@@ -239,9 +245,11 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit)))
}
// Saved without its PDF part: the editing copy is all the file has.
// Saved without its PDF part: the editing copy is all the file has (`pages` that
// name every page read it as the whole file).
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
let whole = LoadOptions { pages: None, ..opts.clone() };
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }).unwrap_or(Err(e))
}
Err(e) => Err(e),
})?
@@ -481,6 +489,10 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
/// either, become RGBA too, whether they store their channels together or one plane per channel.
/// A Targa file, which starts with no signature, is recognized by its header
/// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores
/// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels
/// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw.
/// A TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
@@ -493,6 +505,9 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
}
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?;
if reader.format().is_none() && super::tgaread::is_targa(bytes) {
reader.set_format(image::ImageFormat::Tga);
}
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_RASTER_ALLOC);
reader.limits(limits);
@@ -511,13 +526,25 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let cms = vectorcraft_color::cms::active();
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
};
let img = match (f.id == "tiff").then(extra_samples).flatten() {
let mut img = match (f.id == "tiff").then(extra_samples).flatten() {
Some(img) => img,
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
Some(read) => read.map_err(err)?,
None => reader.decode().map_err(err)?.to_rgba8(),
None => {
use image::ImageDecoder as _;
let decoder = reader.into_decoder().map_err(err)?;
// The allowance counts the decoded samples and the RGBA pixels they become (#1717).
let (w, h) = decoder.dimensions();
if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC {
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
}
image::DynamicImage::from_decoder(decoder).map_err(err)?.to_rgba8()
}
},
};
if f.id == "tga" {
super::tgaread::fix_alpha(bytes, &mut img);
}
as_png(img, ppi)
}
+91 -12
View File
@@ -46,6 +46,7 @@ mod save;
mod screens;
mod svg;
mod text;
mod tgaread;
pub mod zip;
use serde_json::{Value, json};
@@ -206,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
"PDF Info",
[],
None,
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}",
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}; a PostScript file (an EPS, a .ps file, a .ai in PostScript form) → {pages: 1, needsPassword: false, pageInfo: [{width, height (its bounding box, a US Letter page without one), rotation: 0, boxes: {}}]} (password and cropTo don't apply; thumbnail: 1 shows what the page prints)",
always,
pdfimport::pdf_info
),
@@ -646,11 +647,20 @@ pub const FORMATS: &[Format] = &[
},
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: "tga",
label: "Targa",
extensions: &["tga", "vda", "icb", "vst"],
mime: "image/x-tga",
read: true,
write: true,
raster: true,
options: TGA_OPTIONS,
},
Format { id: "psd", label: "PSD", extensions: &["psd"], mime: "image/x-psd", read: true, write: true, raster: true, options: PSD_OPTIONS },
// Affinity Photo's `.afphoto` opens by its content but isn't listed: Finder shouldn't offer a
// vector app for a raster editor's documents.
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/vnd.affinity", false),
reader("affinity", "Affinity", &["af", "afdesign", "afpub"], "application/x-affinity", false),
// Photoshop's large document format: read like a PSD (its merged image), never written.
reader("psb", "PSB", &["psb"], "image/x-psb", true),
// PostScript programs, read as EPS files are; Print to PostScript writes them, exports don't.
@@ -674,6 +684,10 @@ pub const OPEN_EXTS: &[&str] = &[
"tif",
"tiff",
"bmp",
"tga",
"vda",
"icb",
"vst",
"psd",
"psb",
"vctemplate",
@@ -688,11 +702,12 @@ pub const OPEN_EXTS: &[&str] = &[
"afpub",
];
/// Extensions in [`OPEN_EXTS`] the desktop packaging doesn't associate with VectorCraft: Photoshop
/// documents belong to raster editors, so Finder and file managers don't offer a vector app for
/// them, and the packaging declares no `.epsf` or `.ps` type (File › Open and Place still read
/// them all).
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps"];
/// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to
/// raster editors, so Finder and file managers don't offer a vector app for them, and there is no
/// `.epsf`, `.ps` or Targa type (File › Open and Place still read them all). On Linux the desktop
/// entry's EPS type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft
/// for it.
pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda", "icb", "vst"];
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
@@ -733,6 +748,10 @@ pub const PLACE_EXTS: &[&str] = &[
"tif",
"tiff",
"bmp",
"tga",
"vda",
"icb",
"vst",
"psd",
"psb",
"vctemplate",
@@ -1085,14 +1104,74 @@ pub(crate) fn absolute_path(path: &str) -> String {
/// stored in, when it has one: a relative path is taken from its folder. Only files on this
/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a
/// path that is still relative (no document) is refused, as the working directory isn't the
/// document's choice. Every stored path reaches the file system through this.
/// document's choice. On Windows, a document on a mapped drive is on the share behind the drive
/// ([`share_path`]): a path refused as on another computer is checked again against the
/// document's path on that share. Every stored path reaches the file system through this.
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
document_path_with(stored, document, share_path)
}
/// [`document_path`], with `share` giving a document's path on the network share behind its drive.
pub(crate) fn document_path_with(
stored: &str,
document: Option<&str>,
share: impl FnOnce(&str) -> Option<String>,
) -> std::result::Result<String, craft_docpath::Refused> {
use craft_docpath::Refused;
let document = document.map(absolute_path);
let path = craft_docpath::resolve(stored, document.as_deref().map(std::path::Path::new))?;
let resolve = |document: Option<&str>| craft_docpath::resolve(stored, document.map(std::path::Path::new));
let path = match resolve(document.as_deref()) {
Err(Refused::Network) => {
let share = document.as_deref().and_then(share).ok_or(Refused::Network)?;
resolve(Some(share.as_str()))?
}
path => path?,
};
if !path.is_absolute() {
return Err(craft_docpath::Refused::Invalid);
return Err(Refused::Invalid);
}
path.into_os_string().into_string().map_err(|_| craft_docpath::Refused::Invalid)
path.into_os_string().into_string().map_err(|_| Refused::Invalid)
}
/// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is
/// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may
/// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as
/// `\\?\UNC\server\share\…`.
#[cfg(windows)]
pub(crate) fn share_path(document: &str) -> Option<String> {
share_of(document, |folder| {
may_read(folder).ok()?;
std::fs::canonicalize(folder).ok()
})
}
#[cfg(not(windows))]
pub(crate) fn share_path(_: &str) -> Option<String> {
None
}
/// The path of `document` on the network share its drive is mapped to, given `final_path`, which
/// returns the final path of a folder: `None` when `document` is not on a drive (`C:\` or `C:/`)
/// or `final_path` gives no share (`\\?\UNC\server\share\…`), such as a folder on a local volume.
#[cfg(any(windows, test))]
pub(crate) fn share_of(document: &str, final_path: impl FnOnce(&str) -> Option<std::path::PathBuf>) -> Option<String> {
let mut chars = document.chars();
if !matches!((chars.next(), chars.next(), chars.next()), (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic()) {
return None;
}
// The drive's root folder keeps its separator (`Z:\`).
let split = document.rfind(['\\', '/'])?;
let (folder, name) = (document.get(..split.max(3))?, document.get(split + 1..)?);
if name.is_empty() {
return None;
}
let target = final_path(folder)?;
let target = target.to_str()?;
let unc = target.get(..8).filter(|p| p.eq_ignore_ascii_case(r"\\?\UNC\")).and(target.get(8..))?.trim_end_matches('\\');
if unc.split('\\').filter(|c| !c.is_empty()).count() < 2 {
return None;
}
Some(format!(r"\\{unc}\{name}"))
}
#[cfg(target_arch = "wasm32")]
+1 -1
View File
@@ -242,7 +242,7 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
let now = vectorcraft_doc::metadata::now_unix();
let now = vectorcraft_doc::metadata::output_time();
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
}
+42 -10
View File
@@ -1,6 +1,6 @@
//! Opening and placing PDF (and PDF-compatible `.ai`/`.ait`) files: the options `document.open`
//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages,
//! boxes, a thumbnail) for the Import PDF dialog.
//! boxes, a thumbnail; one page for a PostScript file) for the Import PDF dialog.
use serde_json::{Value, json};
use vectorcraft_doc::{ColorMode, Document};
@@ -143,13 +143,13 @@ pub(super) fn import(bytes: &[u8], o: &LoadOptions, editor_document: bool) -> Re
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
/// layer of what it shows (text as outlines: it looks as printed), and the import notes.
/// layer of what it shows, with the same text choice as Open, and the import notes.
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let opts = ImportOptions {
pages: Some(vec![page]),
crop: o.crop,
password: o.password.clone(),
text_as: TextAs::Outlines,
text_as: o.text_as,
layers: false,
..Default::default()
};
@@ -162,6 +162,10 @@ const INFO: &str = "document.pdfInfo";
pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
let bytes = source(p, INFO)?.bytes;
let o = LoadOptions::from_params(INFO, p)?;
// Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them.
if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) {
return postscript_info(&bytes, p);
}
let info = match vectorcraft_pdf::info(&bytes, o.password.as_deref()) {
Ok(i) => i,
Err(e @ (PdfError::NeedsPassword | PdfError::WrongPassword)) => {
@@ -179,14 +183,42 @@ pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let mut out = json!({"pages": pages.len(), "needsPassword": false, "pageInfo": pages});
if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) {
let page = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).filter(|i| *i < info.pages.len());
let page = page.ok_or_else(|| bad(INFO, format!("thumbnail: no page {n} (the PDF has {})", info.pages.len())))?;
if let Some(page) = thumbnail_page(p, info.pages.len(), "PDF")? {
let (doc, _) = page_document(&bytes, page, &o)?;
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
let png = super::encode(&doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
out["thumbnail"] = json!(vectorcraft_format::base64_encode(&png));
out["thumbnail"] = json!(thumbnail(&doc, p)?);
}
Ok(out)
}
/// The page (0-based) `document.pdfInfo`'s `thumbnail` names, from 1 to `count`; `None` when it is
/// absent or null. Any other value (0, a negative or fractional number, a string) is an error that
/// names the pages the `what` has.
fn thumbnail_page(p: &Value, count: usize, what: &str) -> Result<Option<usize>> {
let Some(v) = p.get("thumbnail").filter(|v| !v.is_null()) else { return Ok(None) };
let page = v.as_u64().and_then(|n| usize::try_from(n).ok()).and_then(|n| n.checked_sub(1)).filter(|i| *i < count);
page.map(Some).ok_or_else(|| bad(INFO, format!("thumbnail: no page {v} (the {what} has {count})")))
}
/// `document.pdfInfo` of a PostScript file (an EPS, a `.ps` file, a `.ai` in PostScript form): one
/// page, the page [`vectorcraft_eps::page`] reads, with no PDF boxes. Its thumbnail is the file as
/// the EPS reader reads it without any editing data it carries: what the page prints, or the
/// file's preview when its PostScript can't be read.
fn postscript_info(bytes: &[u8], p: &Value) -> Result<Value> {
let page = vectorcraft_eps::page(bytes).map_err(err)?;
let info = json!({"width": page.width(), "height": page.height(), "rotation": 0, "boxes": {}});
let mut out = json!({"pages": 1, "needsPassword": false, "pageInfo": [info]});
if thumbnail_page(p, 1, "file")?.is_some() {
let doc = vectorcraft_eps::import_with(bytes, false).map_err(err)?.document;
out["thumbnail"] = json!(thumbnail(&doc, p)?);
}
Ok(out)
}
/// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer
/// side, base64.
fn thumbnail(doc: &Document, p: &Value) -> Result<String> {
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
let png = super::encode(doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
Ok(vectorcraft_format::base64_encode(&png))
}
+1 -1
View File
@@ -387,7 +387,7 @@ impl<'a> Naming<'a> {
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
let when = match o.date.as_deref() {
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
None => vectorcraft_doc::metadata::output_time().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
};
let per_file = match o.counter_per.as_deref() {
None | Some("item") => false,
+16
View File
@@ -189,6 +189,22 @@ fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
}
/// A 16-bit RGB TIFF whose samples need more than the allowance (6 bytes a pixel), though its RGBA
/// pixels would fit, is refused as too large to draw before it is decoded.
#[test]
fn tiffs_whose_samples_pass_the_allowance_are_too_large() {
let plane = |v: u16| vec![v; 6];
let mut file = hand_tiff(2, 16, &[], false, &[plane(9000), plane(30_000), plane(60_000)]);
// The loop sets ImageWidth, ImageLength and RowsPerStrip (the first, second and eighth entries) to `side`.
let side = 20_000u32;
assert!(u64::from(side * side) * 6 > MAX_RASTER_ALLOC && u64::from(side * side) * 4 <= MAX_RASTER_ALLOC);
for at in [18, 30, 102] {
file[at..at + 4].copy_from_slice(&side.to_le_bytes());
}
let e = raster_image(&file).err().expect("refused");
assert_eq!(e.to_string(), "an image of 20000 × 20000 pixels is too large to draw");
}
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
/// reads (#1466).
#[test]
@@ -229,6 +229,19 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
assert!(e.contains("placeholder"), "{e}");
}
#[test]
fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
let file = ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER);
for pages in [json!("1"), json!(1), json!("1-")] {
let mut s = Session::new();
open(&mut s, "art.ai", &file, json!({"pages": pages})).unwrap();
check_sample(&s);
}
// Without editing data, the placeholder still isn't opened as the art.
let e = open(&mut Session::new(), "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({"pages": "1"})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
@@ -1,6 +1,8 @@
//! 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 std::io::Write as _;
use serde_json::{Value, json};
use super::tests_svgedit::{comparable, rich};
@@ -93,6 +95,33 @@ fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() {
assert_eq!(images(&s), [want.clone(), want]);
}
/// A 4-bit gray image compressed with Flate and TIFF predictor 2 draws the samples it had before
/// differencing, in an EPS and in a PostScript `.ai`.
#[test]
fn tiff_predicted_4_bit_images_open_from_eps_and_postscript_ai_files() {
// Two rows of four samples, 0 5 15 3 and 9 9 2 0, differenced to 0 5 10 4 and 9 0 9 14.
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
z.write_all(&[0x05, 0xa4, 0x90, 0x9e]).unwrap();
let hex: String = z.finish().unwrap().iter().map(|b| format!("{b:02x}")).collect();
let ps = format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n%%EndComments\n40 20 scale 4 2 4 [4 0 0 -2 0 2] currentfile /ASCIIHexDecode filter \
<< /Predictor 2 /Colors 1 /BitsPerComponent 4 /Columns 4 >> /FlateDecode filter image\n{hex}>\nshowpage\n%%EOF\n"
);
for name in ["predicted.eps", "predicted.ai"] {
let mut s = Session::new();
let r = open(&mut s, name, ps.as_bytes()).unwrap();
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
let d = &s.doc().unwrap().doc;
let mut grays = vec![];
d.walk(|n| {
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
grays = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8().into_raw();
}
});
assert_eq!(grays, [0, 5, 15, 3, 9, 9, 2, 0].map(|v| v * 17), "{name}");
}
}
#[test]
fn an_unreadable_eps_file_comes_in_as_its_preview() {
let mut s = Session::new();
@@ -69,6 +69,33 @@ fn pages_changed_elsewhere_open_as_artwork_with_a_warning() {
assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}");
}
/// `pdf` with its editing file's description rewritten by another app in UTF-16BE with a byte
/// order mark: "hash " and the hash it held, padded with spaces to its length.
fn utf16_described(pdf: &[u8]) -> Vec<u8> {
let from = b"(VectorCraft editing data; page hash ";
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
let mut desc = b"(\xFE\xFF".to_vec();
desc.extend(b"hash ".iter().chain(hash).flat_map(|&c| [0, c]));
desc.push(b')');
desc.resize(len, b' ');
patched(pdf, &pdf[at..at + len], &desc)
}
#[test]
fn pages_changed_elsewhere_are_noticed_with_a_utf16_description() {
let mut s = rich();
let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap());
let ai = b64(&s.execute("document.export", &json!({"format": "ai", "compress": false})).unwrap());
for (name, file) in [("edited.pdf", pdf), ("edited.ai", ai)] {
let r = open(&mut s, name, &utf16_described(&file));
assert_eq!((&r["warnings"], &r["restored"]), (&json!([]), &json!(true)), "{name}: the same pages: {r}");
let edited = patched(&file, b"/MediaBox[0 0 300 200]", b"/MediaBox[0 0 300 201]");
let r = open(&mut s, name, &utf16_described(&edited));
assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_STALE), &json!(false)), "{name}: {r}");
}
}
#[test]
fn the_legacy_payload_name_is_accepted() {
let mut s = rich();
@@ -38,6 +38,64 @@ fn text_opens_as_type_or_outlines() {
assert!(e.contains("textAs must be one of text, outlines"), "{e}");
}
fn live_text_pdf() -> Vec<u8> {
let page = |text: &str| PdfPage {
resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(),
..PdfPage::new(200.0, 100.0, &format!("BT /F1 12 Tf 10 50 Td ({text}) Tj ET"))
};
pdf(&[page("First page"), page("Second page")], None)
}
fn live_texts(s: &Session) -> Vec<String> {
let mut texts = vec![];
s.doc().unwrap().doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
texts.push(t.plain_text());
}
});
texts
}
#[test]
fn pdf_place_keeps_live_text_from_bytes_and_unlinked_paths() {
let bytes = live_text_pdf();
let dir = vectorcraft_testkit::temp_dir("pdf-place-live-text");
let path = dir.join("text.pdf");
std::fs::write(&path, &bytes).unwrap();
for source in [json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)}), json!({"path": path, "link": false})] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let mut p = source;
p["page"] = json!(2);
let r = s.execute("file.place", &p).unwrap();
assert_eq!(live_texts(&s), ["Second page"], "{r}");
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn pdf_clipboard_paste_keeps_live_text_on_the_selected_page() {
let bytes = live_text_pdf();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let r = s.execute("clipboard.importPdf", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "page": 2})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(live_texts(&s), ["Second page"], "{r}");
assert!(!r["warnings"].to_string().contains("outlines"), "{r}");
}
#[test]
fn pdf_place_honors_explicit_text_outlines() {
let bytes = live_text_pdf();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let r =
s.execute("file.place", &json!({"name": "text.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes), "textAs": "outlines"})).unwrap();
assert!(live_texts(&s).is_empty());
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
}
#[test]
fn layers_option_keeps_or_flattens_optional_content() {
let (a, b) = (first_extra(1), first_extra(1) + 1);
@@ -157,27 +157,31 @@ fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string();
assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}");
}
// An EPS with a binary header too, and the PDF info of either.
// An EPS with a binary header too, and the PDF info of each. The plain program's PDF info gives
// its page, and its thumbnail gives the reader's error; the binary file's header points at no
// PostScript, so its PDF info is an error.
let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6];
binary.extend_from_slice(&[0; 28]);
binary.extend_from_slice(ps);
assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript"));
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap();
assert_eq!((v["pages"].as_u64(), v["pageInfo"][0]["width"].as_f64()), (Some(1), Some(100.0)), "{v}");
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps), "thumbnail": 1})).unwrap_err().to_string();
assert!(e.contains("PostScript") && e.contains("frobnicate"), "{e}");
assert!(s.active().is_none());
}
/// PostScript given where a PDF is read (`document.pdfInfo`, `clipboard.importPdf`) is refused
/// with an error that names File › Open and File › Place, and both of those read the file.
/// PostScript given where a PDF is read (`clipboard.importPdf`) is refused with an error that
/// names File › Open and File › Place, and both of those read the file.
#[test]
fn postscript_read_as_a_pdf_names_open_and_place() {
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n";
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for cmd in ["document.pdfInfo", "clipboard.importPdf"] {
let e = s.execute(cmd, &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{cmd}: {e}");
assert!(!e.contains("can't be opened"), "{cmd}: {e}");
}
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string();
assert!(e.contains("PostScript") && e.contains("not a PDF: File › Open and File › Place read it as EPS"), "{e}");
assert!(!e.contains("can't be opened"), "{e}");
for name in ["legacy.ai", "art.eps"] {
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap();
assert_eq!(r["warnings"], json!([]), "{name}: {r}");
@@ -186,6 +190,65 @@ fn postscript_read_as_a_pdf_names_open_and_place() {
}
}
/// `document.pdfInfo` reads PostScript (an EPS, a `.ai` in PostScript form, a `.ps` file) as one
/// page, its bounding box or a letter page without one, and its thumbnail shows that page. Each
/// file here opens on that page with `editingData: false`.
#[test]
fn pdf_info_reads_postscript_as_one_page() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
let data = |r: Value| vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let eps = data(s.execute("document.exportEps", &json!({"useArtboards": true})).unwrap());
let ai = data(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap());
assert!(eps.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) && ai.starts_with(b"%!PS"));
let hires = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 101 41\n%%HiResBoundingBox: 0 0 100.5 40.25\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
let letter = b"%!PS\n1 0 0 setrgbcolor 72 72 144 72 rectfill\nshowpage\n";
let bare = b"%!\n0 0 1 setrgbcolor 100 100 50 50 rectfill\nshowpage\n";
for (name, bytes, size) in [
("art.eps", &eps[..], (300.0, 200.0)),
("legacy.ai", &ai, (101.0, 51.0)),
("hires.eps", hires, (100.5, 40.25)),
("page.ps", letter, (612.0, 792.0)),
("bare.ps", bare, (612.0, 792.0)),
] {
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 100})).unwrap();
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert_eq!(img.width().max(img.height()), 100, "{name}");
let mut v = v;
v["thumbnail"] = json!("png");
let page = json!({"width": size.0, "height": size.1, "rotation": 0, "boxes": {}});
assert_eq!(v, json!({"pages": 1, "needsPassword": false, "pageInfo": [page], "thumbnail": "png"}), "{name}");
s.execute("document.open", &json!({"name": name, "dataBase64": b64(bytes), "editingData": false})).unwrap();
let board = s.doc().unwrap().doc.artboards[0].rect;
assert_eq!((board.width(), board.height()), size, "{name}: the page document.open reads");
}
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&eps), "thumbnail": 2})).unwrap_err().to_string();
assert!(e.contains("no page 2"), "{e}");
}
/// `document.pdfInfo`'s `thumbnail` is a page number from 1, for a PDF and for PostScript alike:
/// any other value is an error that names the pages, and an absent or null one gives no thumbnail.
#[test]
fn pdf_info_refuses_a_thumbnail_that_is_not_a_page_number() {
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EndComments\n0 0 1 setrgbcolor 0 0 50 20 rectfill\n";
let mut s = Session::new();
for (bytes, pages) in [(&three_pages()[..], "(the PDF has 3)"), (ps, "(the file has 1)")] {
for n in [json!(-1), json!(0), json!(1.5), json!("1"), json!(true)] {
let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": n})).unwrap_err().to_string();
assert!(e.contains(&format!("thumbnail: no page {n} {pages}")), "{n}: {e}");
}
for none in [json!({"dataBase64": b64(bytes)}), json!({"dataBase64": b64(bytes), "thumbnail": null})] {
assert!(s.execute("document.pdfInfo", &none).unwrap().get("thumbnail").is_none(), "{pages}");
}
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(bytes), "thumbnail": 1, "thumbnailSize": 64})).unwrap();
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert_eq!(img.width().max(img.height()), 64, "{pages}");
}
}
/// A page showing only text, with an editor's private data when `private`.
fn text_page(private: bool, also: &str) -> PdfPage {
PdfPage {
+148 -2
View File
@@ -1,5 +1,5 @@
//! TIFF, BMP and Targa export through `document.export`: the options reach the files, impossible
//! ones are refused, and the files open again.
//! ones are refused, and the files open again; Targa files open and place.
use serde_json::{Value, json};
@@ -135,6 +135,152 @@ fn targa_depth_reaches_the_file() {
}
}
/// The RGBA pixels of the image `document.open` or `file.place` stored for the first object of the
/// active document's first layer.
fn stored_pixels(s: &Session) -> image::RgbaImage {
let doc = &s.doc().unwrap().doc;
let im = match &doc.layers[0].children().unwrap()[0].kind {
vectorcraft_doc::NodeKind::Image(im) => im.clone(),
other => panic!("expected an image, got {other:?}"),
};
image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgba8()
}
/// A Targa header: no image id or color map, image type `kind`, `w` × `h` pixels of `depth` bits,
/// image descriptor `descriptor`.
fn targa_header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec<u8> {
let mut f = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0];
f.extend(w.to_le_bytes());
f.extend(h.to_le_bytes());
f.extend([depth, descriptor]);
f
}
/// Targa files open and place at their pixel size with the transparency they store, by any of the
/// four Targa extensions; copied in a file manager they paste as a bitmap; the dialogs list them.
#[test]
fn targa_files_open_and_place() {
use crate::cmd::clipboard::{PASTE_ORDER, file_flavour};
let mut s = session();
let b64 = vectorcraft_format::base64_encode;
// VectorCraft's own exports: the transparent corner stays transparent at 16 and 32 bits (24
// bits are flattened on white) and the black rectangle stays black.
for depth in [16, 24, 32] {
let tga = export(&mut s, json!({"format": "tga", "depth": depth}));
let r = s.execute("document.open", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
assert_eq!((&r["format"], &r["warnings"]), (&json!("tga"), &json!([])), "{depth} bits: {r}");
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(60.0, 40.0));
let px = stored_pixels(&s);
assert_eq!(px.dimensions(), (60, 40));
let corner = if depth == 24 { [255; 4] } else { [0, 0, 0, 0] };
assert_eq!(px.get_pixel(5, 5).0[3], corner[3], "{depth} bits: the corner's alpha");
if depth == 24 {
assert_eq!(px.get_pixel(5, 5).0, corner, "flattened on white");
}
let black = px.get_pixel(40, 20).0;
assert!(black[3] == 255 && black[..3].iter().all(|c| *c < 80), "{depth} bits: {black:?}");
// The next export starts from the session's document again.
s = session();
}
let tga = export(&mut s, json!({"format": "tga", "depth": 32}));
for name in ["art.vda", "ART.ICB", "art.vst"] {
let r = s.execute("document.open", &json!({"name": name, "dataBase64": b64(&tga)})).unwrap();
assert_eq!(r["format"], "tga", "{name}: {r}");
}
s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap();
let r = s.execute("file.place", &json!({"name": "art.tga", "dataBase64": b64(&tga)})).unwrap();
assert_eq!((&r["format"], r["ids"].as_array().map(Vec::len)), (&json!("tga"), Some(1)), "{r}");
assert_eq!(stored_pixels(&s).get_pixel(5, 5).0[3], 0, "placed with its transparency");
// Copied in a file manager, a Targa file pastes as a bitmap.
assert_eq!(file_flavour("art.tga", &tga, &PASTE_ORDER), Some("image/x-tga"));
let r = s.execute("clipboard.importImage", &json!({"mime": "image/x-tga", "dataBase64": b64(&tga)})).unwrap();
assert_eq!((&r["count"], &r["pixelWidth"], &r["pixelHeight"]), (&json!(1), &json!(60), &json!(40)), "{r}");
// The dialogs list the four extensions; the desktop packaging doesn't associate them.
for e in ["tga", "vda", "icb", "vst"] {
assert!(OPEN_EXTS.contains(&e) && PLACE_EXTS.contains(&e) && UNASSOCIATED_EXTS.contains(&e), "{e}");
}
let targa = ("Targa", &["tga", "vda", "icb", "vst"][..]);
assert!(open_filters().any(|f| f == targa) && place_filters().any(|f| f == targa));
}
/// Targa pixels in every layout: run-length encoded rows stored top down (as the `image` crate
/// writes them), a 16-bit file whose one attribute bit per pixel is its alpha, with runs, stored
/// right to left and top down, 32-bit files with no attribute bits (opaque), with eight, and with
/// each Attributes Type in an extension area, and color-mapped files whose 16-bit entries carry the
/// attribute bit.
#[test]
fn targa_layouts_and_alpha_read() {
let src = image::RgbaImage::from_fn(3, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 200, 50, if x == 1 { 128 } else { 255 }]));
let mut rle = Vec::new();
src.write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
assert_eq!((rle[2], rle[17] & 0x20), (10, 0x20), "run-length encoded true color, stored top down");
assert_eq!(raster_image(&rle).map(|r| image::load_from_memory(&r.blob.bytes).unwrap().to_rgba8()).unwrap(), src);
// 3 × 2, 5-5-5 color and an attribute bit, right to left (bit 4), top down (bit 5): a raw
// packet of red, blue and a clear pixel for the first row, then a run of green.
let mut f = targa_header(10, 3, 2, 16, 0x31);
f.push(0x02);
f.extend([0xfc00u16, 0x801f, 0x0000].iter().flat_map(|v| v.to_le_bytes()));
f.push(0x82);
f.extend(0x83e0u16.to_le_bytes());
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
let px = |x, y| img.get_pixel(x, y).0;
assert_eq!((px(2, 0), px(1, 0), px(0, 0)[3]), ([255, 0, 0, 255], [0, 0, 255, 255], 0), "the first row, right to left");
assert!((0..3).all(|x| px(x, 1) == [0, 255, 0, 255]), "the run");
// 2 × 1 at 32 bits with no attribute bits: the fourth byte isn't alpha, whatever it holds.
for fourth in [[0, 0], [0, 255]] {
let mut f = targa_header(2, 2, 1, 32, 0x20);
f.extend([10, 20, 30, fourth[0], 40, 50, 60, fourth[1]]);
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([30, 20, 10, 255], [60, 50, 40, 255]), "{fourth:?}");
}
// The same with eight attribute bits: the fourth byte is alpha, also when it is 0 everywhere.
let mut f = targa_header(2, 2, 1, 32, 0x28);
f.extend([10, 20, 30, 0, 40, 50, 60, 0]);
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
assert!(img.pixels().all(|p| p.0[3] == 0));
// The extension area's Attributes Type: undefined data (1, 2) isn't alpha, premultiplied colors
// (4) are divided by their alpha, and alpha (3) or 0, the field left unset, keeps it.
for (kind, want) in [(0, [50, 50, 50, 128]), (1, [50, 50, 50, 255]), (2, [50, 50, 50, 255]), (3, [50, 50, 50, 128]), (4, [100, 100, 100, 128])] {
let mut f = targa_header(2, 1, 1, 32, 0x28);
f.extend([50, 50, 50, 128]);
let mut area = vec![0; 495];
area[..2].copy_from_slice(&495u16.to_le_bytes());
area[494] = kind;
let at = f.len() as u32;
f.extend(area);
f.extend(at.to_le_bytes());
f.extend([0; 4]);
f.extend(b"TRUEVISION-XFILE.\0");
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
assert_eq!(img.get_pixel(0, 0).0, want, "Attributes Type {kind}");
}
// Color-mapped, one attribute bit, top down: 16-bit entries 5 (clear black) and 6 (red), as a
// raw row of 8-bit indices and as runs.
for (kind, data) in [(1, vec![6, 5, 6]), (9, vec![0x80, 6, 0x00, 5, 0x80, 6])] {
let mut f = vec![0, 1, kind, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 1, 0, 8, 0x21];
f.extend([0x0000u16, 0xfc00].iter().flat_map(|v| v.to_le_bytes()));
f.extend(data);
let img = image::load_from_memory(&raster_image(&f).unwrap().blob.bytes).unwrap().to_rgba8();
let row: Vec<[u8; 4]> = (0..3).map(|x| img.get_pixel(x, 0).0).collect();
assert_eq!(row, [[255, 0, 0, 255], [0, 0, 0, 0], [255, 0, 0, 255]], "image type {kind}");
}
// A grayscale image the decoder could hold whose RGBA pixels pass the allowance is refused from
// its header.
let huge = targa_header(11, 32768, 32768, 8, 0);
let e = raster_image(&huge).err().expect("refused");
assert_eq!(e.to_string(), "an image of 32768 × 32768 pixels is too large to draw");
// Headers with values the specification doesn't define aren't Targa files.
for bad in [targa_header(2, 0, 1, 24, 0), targa_header(1, 1, 1, 8, 0), targa_header(2, 1, 1, 12, 0), targa_header(2, 1, 1, 24, 0xc0)] {
assert!(!super::tgaread::is_targa(&bad), "{bad:?}");
}
assert!(raster_image(b"nothing an image reader knows").is_err());
}
#[test]
fn the_formats_are_listed_and_write_one_file_per_artboard() {
let mut s = Session::new();
@@ -143,7 +289,7 @@ fn the_formats_are_listed_and_write_one_file_per_artboard() {
let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
let (tiff, bmp, tga) = (find("tiff"), find("bmp"), find("tga"));
assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true)));
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(false)));
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(true)));
for (f, keys) in [
(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..]),
(&bmp, &["colorModel", "depth", "fileFormat", "rle", "flipRows", "reduction", "dither"]),
+152
View File
@@ -0,0 +1,152 @@
//! Reading Targa files (`.tga`, `.vda`, `.icb`, `.vst`) for File › Open and File › Place, from the
//! Truevision TGA File Format Specification, version 2.0. The `image` crate decodes the pixels.
//! A Targa file starts with no signature, so [`is_targa`] checks its header's fields instead, and
//! [`fix_alpha`] gives the decoded image the transparency the file stores. The file is untrusted:
//! every read is checked.
/// Does `bytes` start with a Targa header? The fields must hold values the specification defines:
/// a color map type of 0 or 1; an image type of 1, 2, 3, 9, 10 or 11 (color-mapped, true-color or
/// black-and-white, raw or run-length encoded), with a color map for the color-mapped types; a
/// color map entry size of 15, 16, 24 or 32 bits when there is a map; a width and a height; a pixel
/// depth of 8, 15, 16, 24 or 32 bits; and bits 6 and 7 of the image descriptor clear.
pub(super) fn is_targa(bytes: &[u8]) -> bool {
let [_, map_type, image_type, _, _, _, _, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
return false;
};
matches!(map_type, 0 | 1)
&& matches!(image_type, 1 | 2 | 3 | 9 | 10 | 11)
&& (map_type == 1 || !matches!(image_type, 1 | 9))
&& (map_type == 0 || matches!(entry_bits, 15 | 16 | 24 | 32))
&& u16::from_le_bytes([w0, w1]) > 0
&& u16::from_le_bytes([h0, h1]) > 0
&& matches!(depth, 8 | 15 | 16 | 24 | 32)
&& descriptor & 0xc0 == 0
}
/// Give `img`, decoded from Targa file `bytes`, the transparency the file stores. Bits 0 to 3 of
/// the image descriptor give the number of attribute bits per pixel, and the extension area's
/// Attributes Type, when the file has one, says what they hold:
/// - with no attribute bits, or attribute data the Attributes Type calls undefined (1 or 2), the
/// image is opaque, whatever a 32-bit pixel's fourth byte holds (the decoder reads it as alpha);
/// - with one bit, a 16-bit true-color pixel takes that bit as its alpha (clear: transparent), and
/// a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; the decoder drops
/// the bit;
/// - with more, the decoder's alpha stays, and colors the Attributes Type calls premultiplied (4)
/// are divided by it.
///
/// An Attributes Type of 0 ("no alpha") with attribute bits in the descriptor counts as alpha.
pub(super) fn fix_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
let Some(bits) = bytes.get(17).map(|d| d & 0x0f) else { return };
let kind = attributes_type(bytes);
if bits == 0 || matches!(kind, Some(1 | 2)) {
img.pixels_mut().for_each(|p| p.0[3] = 255);
return;
}
if bits == 1 {
one_bit_alpha(bytes, img);
}
if kind == Some(4) {
for p in img.pixels_mut() {
let a = u16::from(p.0[3]);
for c in &mut p.0[..3] {
// A fully transparent pixel keeps its colors.
if let Some(v) = (u16::from(*c) * 255 + a / 2).checked_div(a) {
*c = v.min(255) as u8;
}
}
}
}
}
/// The Attributes Type of a Targa 2.0 file's extension area: `None` without the footer's
/// `TRUEVISION-XFILE.` signature, an extension area, or one of the 495 bytes the specification
/// gives it.
fn attributes_type(bytes: &[u8]) -> Option<u8> {
let footer = bytes.len().checked_sub(26)?;
if bytes.get(footer + 8..)? != b"TRUEVISION-XFILE.\0" {
return None;
}
let at = usize::try_from(u32::from_le_bytes(bytes.get(footer..footer + 4)?.try_into().ok()?)).ok()?;
// The area follows the header and ends before the footer; its first field is its size.
if at < 18 || at.checked_add(495)? > footer || u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?]) != 495 {
return None;
}
bytes.get(at + 494).copied()
}
/// The attribute bit of each pixel of a Targa image with one attribute bit per pixel, as `img`'s
/// alpha: the top bit of a 16-bit true-color pixel, or of the 16-bit color map entry an 8- or
/// 16-bit index names, raw or run-length encoded. Rows and columns are stored in the order the
/// image descriptor's bits 5 (top to bottom) and 4 (right to left) give. Other images, and pixels
/// the data doesn't reach, keep the alpha they have.
fn one_bit_alpha(bytes: &[u8], img: &mut image::RgbaImage) {
let [id_len, map_type, image_type, o0, o1, m0, m1, entry_bits, _, _, _, _, w0, w1, h0, h1, depth, descriptor, ..] = *bytes else {
return;
};
let (w, h) = (u32::from(u16::from_le_bytes([w0, w1])), u32::from(u16::from_le_bytes([h0, h1])));
let mapped = matches!(image_type, 1 | 9);
// The number of bytes each pixel takes.
let size = match image_type {
2 | 10 if depth == 16 => 2,
1 | 9 if map_type == 1 && entry_bits == 16 && matches!(depth, 8 | 16) => usize::from(depth / 8),
_ => return,
};
if img.dimensions() != (w, h) {
return;
}
let (first, entries) = (u16::from_le_bytes([o0, o1]), u16::from_le_bytes([m0, m1]));
let map_at = 18 + usize::from(id_len);
let map_len = if map_type == 1 { usize::from(entries) * usize::from(entry_bits).div_ceil(8) } else { 0 };
// The attribute bit is the top bit of the little-endian pixel or color map entry.
let attribute = |at: usize| -> Option<bool> {
let px = bytes.get(at..at.checked_add(size)?)?;
let high = if mapped {
let index = match *px {
[i] => u16::from(i),
[lo, hi] => u16::from_le_bytes([lo, hi]),
_ => return None,
};
let entry = index.checked_sub(first).filter(|e| *e < entries)?;
*bytes.get(map_at + usize::from(entry) * 2 + 1)?
} else {
*px.get(1)?
};
Some(high & 0x80 != 0)
};
let (top_down, right_to_left) = (descriptor & 0x20 != 0, descriptor & 0x10 != 0);
let total = u64::from(w) * u64::from(h);
let mut set = |i: u64, opaque: bool| {
let (row, col) = ((i / u64::from(w)) as u32, (i % u64::from(w)) as u32);
let y = if top_down { row } else { h - 1 - row };
let x = if right_to_left { w - 1 - col } else { col };
img.get_pixel_mut(x, y).0[3] = if opaque { 255 } else { 0 };
};
let mut at = map_at + map_len;
let mut i = 0;
while i < total {
if image_type < 9 {
let Some(opaque) = attribute(at) else { return };
set(i, opaque);
(i, at) = (i + 1, at + size);
continue;
}
let Some(&packet) = bytes.get(at) else { return };
let count = u64::from(packet & 0x7f) + 1;
at += 1;
if packet & 0x80 != 0 {
// A run packet repeats one pixel.
let Some(opaque) = attribute(at) else { return };
at += size;
for _ in 0..count.min(total - i) {
set(i, opaque);
i += 1;
}
} else {
for _ in 0..count.min(total - i) {
let Some(opaque) = attribute(at) else { return };
set(i, opaque);
(i, at) = (i + 1, at + size);
}
}
}
}
+7 -2
View File
@@ -341,6 +341,7 @@ pub(crate) fn transform_each_again(s: &mut Session, p: &Value) -> Result<Value>
/// repeats; a dialog's preview records it as it commits (`Session::commit_interaction`).
fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
let roots = selected_roots(s)?;
check_numbers(p, &["scaleH", "scaleV", "moveH", "moveV", "rotate"], "object.transformEach")?;
// Bounded so coordinates stay finite (and the document stays serializable).
let sh = f64_or(p, "scaleH", 100.0).clamp(-100_000.0, 100_000.0);
let sv = f64_or(p, "scaleV", 100.0).clamp(-100_000.0, 100_000.0);
@@ -480,6 +481,7 @@ impl Rasterized {
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels.
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> {
check_numbers(p, &["ppi"], cmd)?;
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
if !(1.0..=2400.0).contains(&ppi) {
return Err(bad(cmd, "ppi must be between 1 and 2400"));
@@ -600,6 +602,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
check_numbers(p, &["width", "height", "spacingX", "spacingY"], C)?;
let (nw, nh) = (f64_or(p, "width", b.width()).max(0.1), f64_or(p, "height", b.height()).max(0.1));
let resize = Affine::translate(b.origin().to_vec2())
* Affine::scale_non_uniform(nw / b.width().max(1e-9), nh / b.height().max(1e-9))
@@ -671,8 +674,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
return trim_image(s, id, (**im).clone());
}
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
let rect = match p.get("rect").and_then(Value::as_array) {
Some(a) if a.len() == 4 => {
let rect = match p.get("rect").filter(|v| !v.is_null()) {
Some(Value::Array(a)) if a.len() == 4 => {
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
if v.len() != 4 {
return Err(bad(C, "rect must be [x, y, width, height]"));
@@ -832,6 +835,7 @@ impl Session {
fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.createTrimMarks";
check_numbers(p, &["offset", "length", "bleed", "weight"], C)?;
let style = match str_param(p, "style") {
Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?,
None => s.crop_mark_style(),
@@ -1001,6 +1005,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
L::Row => (1, n_ab),
L::Column => (n_ab, 1),
};
check_numbers(p, &["spacing"], CMD)?;
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
let move_art = bool_or(p, "moveArtwork", true);
let locked_and_hidden = s.prefs.move_locked_with_artboard;
+3 -1
View File
@@ -47,6 +47,7 @@ pub(crate) mod menucmds;
pub(crate) mod newart;
pub mod newdoc;
mod object;
pub(crate) mod objects_on_path;
mod opacitymask;
mod overprint;
mod package;
@@ -257,6 +258,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(brushsym::specs());
v.extend(patterncmds::specs());
v.extend(intertwine::specs());
v.extend(objects_on_path::specs());
v.extend(prefscmds::specs());
v.extend(distortcmds::specs());
v.extend(paint::proxy_specs());
@@ -500,7 +502,7 @@ pub(crate) fn date_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Optio
/// no clock). It joins the running command's journal entry as `key`, so a replay of the journal
/// gives the same date whenever it runs (an action leaves it out: [`crate::Session::journal_for_action`]).
pub(crate) fn clock_date(s: &mut Session, key: &str, given: Option<Option<i64>>) -> Option<i64> {
let t = given.unwrap_or_else(vectorcraft_doc::metadata::now_unix);
let t = given.unwrap_or_else(vectorcraft_doc::metadata::output_time);
s.note_journal(key, serde_json::json!(t));
t
}
+334
View File
@@ -0,0 +1,334 @@
//! Object › Objects on Path (#2545): objects attached to a path and spread along it.
//!
//! `object.objectsOnPath.attach` puts the selected objects and a path in an Objects on Path group:
//! a group with a [`ObjectsOnPath`] record ([`vectorcraft_doc::objects_on_path`]), the path at its
//! bottom. Called without a path (Object › Objects on Path › Attach) it chooses the Objects on Path
//! tool, whose click on a path attaches the selected objects to that path. The objects go along the
//! path column by column from left to right, top to bottom in each column, spread evenly from its
//! first anchor and turned to follow it. `object.objectsOnPath.options` sets the group's Pivot and
//! Rotate, and `object.objectsOnPath.expand` makes it a plain group.
//!
//! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back
//! on its path: editing the path moves them, an object moved on its own goes back to its place,
//! objects added to or taken out of the group join the end of the path or leave the record, and
//! art that replaces an object at its index (Break Link to Symbol) keeps its place.
//!
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html> (the tool and
//! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution,
//! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand).
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::objects_on_path::{CENTER, ObjectsOnPath, attachable_path, is_objects_on_path, place};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
use super::edit::{roots_of, selected_roots};
use super::*;
/// The id of the Objects on Path tool.
pub const TOOL: &str = "objectsOnPath";
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"object.objectsOnPath.attach",
"Attach",
["Object", "Objects on Path"],
None,
"{path?: id of the path to attach to (without it: choose the Objects on Path tool, whose click on a path attaches the selection to it), ids?: the objects to attach (default: the selection; the path is left out of it), pivot?: 0-8 (the reference point of each object that attaches to the path, row by row from the top left; default 4, the center), rotate?: degrees counter-clockwise from upright on the path (default 0)} put the objects and the path in an Objects on Path group: the path at the bottom, the objects spread evenly along it from its first anchor (the last one at the end of an open path) in their order on the canvas, column by column from left to right and top to bottom in each column, each turned to follow the path; editing the path moves them → {id}",
has_selection,
attach
),
cmd!(
"object.objectsOnPath.options",
"Objects on Path Options",
[],
None,
"{ids?: Objects on Path groups or objects in them (default: the selection), pivot?: 0-8 (the reference point of each object that attaches to the path, row by row from the top left; 4: the center), rotate?: degrees counter-clockwise from upright on the path} set how the objects of the groups attach → {ids}",
has_objects_on_path,
options
),
cmd!(
"object.objectsOnPath.expand",
"Expand",
["Object", "Objects on Path"],
None,
"{ids?: Objects on Path groups or objects in them (default: the selection)} make the groups plain groups of the path and the objects where they are → {ids}",
has_objects_on_path,
expand
),
]
}
// ---------- predicates ----------
/// The Objects on Path groups of `ids`: the ones among them, and the innermost one around each
/// object in one.
fn groups_of(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for id in ids {
let group = d.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| d.node(*a).is_some_and(is_objects_on_path));
if let Some(g) = group
&& !out.contains(&g)
{
out.push(g);
}
}
out
}
/// The Objects on Path groups of the selection ([`groups_of`]).
fn selected_groups(s: &Session) -> Vec<NodeId> {
s.active().map(|st| groups_of(&st.doc, &st.selection.objects)).unwrap_or_default()
}
/// The groups a command acts on: those of `ids` when given, otherwise those of the selection.
fn target_groups(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
if p.get("ids").is_none_or(Value::is_null) {
return Ok(selected_groups(s));
}
let ids = checked_ids_param(s, p, "ids", cmd)?;
let groups = groups_of(&s.doc()?.doc, &ids);
if groups.is_empty() {
return Err(bad(cmd, "`ids` must name Objects on Path groups or objects in them"));
}
Ok(groups)
}
fn has_objects_on_path(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if selected_groups(s).is_empty() { Err("select an Objects on Path group".into()) } else { Ok(()) }
}
/// The Objects on Path group around `id` (not `id` itself), if any.
fn group_around(d: &Document, id: NodeId) -> Option<NodeId> {
d.ancestry(id).unwrap_or_default().into_iter().filter(|a| *a != id).find(|a| d.node(*a).is_some_and(is_objects_on_path))
}
// ---------- Attach ----------
/// `pivot` and `rotate` from `p`, each checked.
fn pivot_rotate(p: &Value, cmd: &str, pivot: u8, rotate: f64) -> Result<(u8, f64)> {
check_numbers(p, &["pivot", "rotate"], cmd)?;
let pivot = match p.get("pivot").filter(|v| !v.is_null()) {
None => pivot,
Some(v) => v.as_u64().filter(|n| *n <= 8).map(|n| n as u8).ok_or_else(|| bad(cmd, "`pivot` must be a whole number from 0 to 8"))?,
};
let rotate = match p.get("rotate").and_then(Value::as_f64) {
None => rotate,
Some(r) if r.is_finite() => r,
Some(_) => return Err(bad(cmd, "`rotate` must be a finite number")),
};
Ok((pivot, rotate))
}
/// `ids` in their order along the path: column by column from left to right, top to bottom in each
/// column. A column is a run of objects, taken by their left edges, that each start left of the
/// right edge of the column so far. Objects without bounds go last. Symbol instances are measured
/// by their art.
fn path_order(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let symbols = d.symbol_boxes();
let mut placed: Vec<(NodeId, Rect)> = vec![];
let mut rest = vec![];
for id in ids {
match d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols)) {
Some(b) => placed.push((*id, b)),
None => rest.push(*id),
}
}
placed.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0));
let mut columns: Vec<(f64, Vec<(NodeId, Rect)>)> = vec![];
for (id, b) in placed {
match columns.last_mut() {
Some((right, column)) if b.x0 < *right => {
*right = right.max(b.x1);
column.push((id, b));
}
_ => columns.push((b.x1, vec![(id, b)])),
}
}
let mut out = vec![];
for (_, mut column) in columns {
column.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0));
out.extend(column.into_iter().map(|(id, _)| id));
}
out.extend(rest);
out
}
/// Why the path `path` can't take objects, if it can't.
fn path_refused(d: &Document, path: NodeId) -> Option<&'static str> {
let n = d.node(path)?;
if attachable_path(n).is_none() {
return Some("pick a path to attach the objects to (not a guide, a compound path or type)");
}
if group_around(d, path).is_some() {
return Some("the path is already in an Objects on Path group");
}
None
}
/// Why `n` can't be attached to a path, if it can't (the help page names graphs).
fn object_refused(d: &Document, n: &Node) -> Option<&'static str> {
if n.is_layer() {
return Some("layers can't be attached to a path: select the objects in them");
}
if group_around(d, n.id).is_some() {
return Some("an object already on a path can't be attached again");
}
let mut graph = false;
n.walk(&mut |c| graph |= c.extras().graph.is_some());
graph.then_some("graphs can't be attached to a path")
}
fn attach(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.objectsOnPath.attach";
let Some(path) = p.get("path").filter(|v| !v.is_null()) else {
let view = s.last_view;
s.select_tool(TOOL, view)?;
return Ok(json!({ "tool": TOOL }));
};
let path = checked_id(s, path, C)?;
let (pivot, rotate) = pivot_rotate(p, C, CENTER, 0.0)?;
let roots = match p.get("ids") {
Some(_) => roots_of(&s.doc()?.doc, checked_ids_param(s, p, "ids", C)?),
None => selected_roots(s)?,
};
let ids: Vec<NodeId> = roots.into_iter().filter(|id| *id != path).collect();
let order = {
let d = &s.doc()?.doc;
if let Some(why) = path_refused(d, path) {
return Err(bad(C, why));
}
if ids.is_empty() {
return Err(bad(C, "select the objects to attach to the path"));
}
for id in &ids {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
if d.ancestry(path).unwrap_or_default().contains(id) {
return Err(bad(C, "the path can't be inside one of the objects to attach"));
}
if let Some(why) = object_refused(d, n) {
return Err(bad(C, why));
}
}
path_order(d, &ids)
};
let id = s.edit("Attach Objects on Path", |d, sel| {
let members: Vec<Node> = order.iter().filter_map(|id| d.node(*id).cloned()).collect();
let rec = ObjectsOnPath::new(path, &members.iter().collect::<Vec<_>>(), pivot, rotate);
let mut wrapped = ids.clone();
wrapped.push(path);
let wrapped = d.paint_order(wrapped);
let id = d.alloc_id();
d.wrap(&wrapped, |mut children| {
// The path at the bottom: the objects are drawn over it.
if let Some(i) = children.iter().position(|c| c.id == path) {
let p = children.remove(i);
children.insert(0, p);
}
let mut g = Node::new(id, NodeKind::Group { children, clip: false });
g.extras_mut().objects_on_path = Some(Box::new(rec));
g
})?;
place_in(d, id);
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
}
// ---------- Options ----------
fn options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.objectsOnPath.options";
let groups = target_groups(s, p, C)?;
pivot_rotate(p, C, CENTER, 0.0)?;
s.edit("Objects on Path Options", |d, _| {
for g in &groups {
let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?;
let Some(rec) = n.extras().objects_on_path.as_deref().cloned() else { continue };
let (pivot, rotate) = pivot_rotate(p, C, rec.pivot, rec.rotate)?;
n.extras_mut().objects_on_path = Some(Box::new(ObjectsOnPath { pivot, rotate, ..rec }));
}
Ok(())
})?;
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
}
// ---------- Expand ----------
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let groups = target_groups(s, p, "object.objectsOnPath.expand")?;
s.edit("Expand Objects on Path", |d, sel| {
for g in &groups {
d.node_mut(*g).ok_or(EngineError::NoNode(*g))?.extras_mut().objects_on_path = None;
}
sel.set(groups.iter().copied());
Ok(())
})?;
Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::<Vec<_>>() }))
}
// ---------- refresh ----------
/// After an edit: put the objects of every Objects on Path group that changed back on its path
/// ([`place`]), inner groups first. Only the parts of the tree that the edit changed are searched.
pub(crate) fn refresh(before: &Document, doc: &mut Document) {
let mut groups = vec![];
changed_groups(&before.layers, &doc.layers, &mut groups);
for id in groups.iter().rev() {
follow_replacements(before, doc, *id);
place_in(doc, *id);
}
}
/// Members of the group `id` that the edit replaced with new art at the same index keep their
/// place on the path ([`ObjectsOnPath::follow_replacements`]).
fn follow_replacements(before: &Document, doc: &mut Document, id: NodeId) {
let (Some(old), Some(new)) = (before.node(id), doc.node(id)) else { return };
let (Some(old), Some(new)) = (old.children(), new.children()) else { return };
let Some(mut rec) = doc.node(id).and_then(|n| n.extras().objects_on_path.as_deref().cloned()) else { return };
let unchanged = rec.clone();
rec.follow_replacements(old, new);
if rec != unchanged
&& let Some(n) = doc.node_mut(id)
{
n.extras_mut().objects_on_path = Some(Box::new(rec));
}
}
/// [`place`] the Objects on Path group `id` of `doc`, measuring symbol instances by their art.
fn place_in(doc: &mut Document, id: NodeId) {
let Some(n) = doc.node(id) else { return };
let mut g = n.clone();
if place(&mut g, &doc.symbol_boxes())
&& let Some(slot) = doc.node_mut(id)
{
*slot = g;
}
}
/// The Objects on Path groups among `after` and inside them that are not the same nodes as before
/// (`before`: the same list before the edit), outer ones first.
fn changed_groups(before: &[Arc<Node>], after: &[Arc<Node>], out: &mut Vec<NodeId>) {
let mut by_id: Option<HashMap<NodeId, &Arc<Node>>> = None;
for (i, a) in after.iter().enumerate() {
let b = match before.get(i) {
Some(b) if b.id == a.id => Some(b),
_ => by_id.get_or_insert_with(|| before.iter().map(|b| (b.id, b)).collect()).get(&a.id).copied(),
};
if b.is_some_and(|b| Arc::ptr_eq(b, a)) {
continue;
}
if is_objects_on_path(a) {
out.push(a.id);
}
if let Some(children) = a.children() {
changed_groups(b.and_then(|b| b.children()).map_or(&[], Vec::as_slice), children, out);
}
}
}
+1 -1
View File
@@ -400,7 +400,7 @@ fn check_no_overwrite(root: &Path, entries: &[Entry], sources: &[&str]) -> Resul
/// The MS-DOS time and date a zip entry records: now (UTC), else 1980-01-01.
fn dos_time() -> (u16, u16) {
let Some(t) = vectorcraft_doc::metadata::now_unix() else { return (0, 0x21) };
let Some(t) = vectorcraft_doc::metadata::output_time() else { return (0, 0x21) };
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
let date = (((y - 1980).clamp(0, 127) << 9) | (mo << 5) | d) as u16;
(((h << 11) | (mi << 5) | (s / 2)) as u16, date)
+43 -9
View File
@@ -7,6 +7,7 @@ use vectorcraft_doc::{NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
use super::create::anchor_from_json;
use super::draw2::usize_opt;
use super::pathops::Beside;
use super::*;
use crate::EngineError;
@@ -37,10 +38,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Direction Handle",
[],
None,
"{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
has_doc,
set_handle
),
cmd!(
"path.moveAnchor",
"Move Anchor Point",
[],
None,
"{id, subpath?, anchor, x, y} move one anchor to (x, y), its handles with it",
has_doc,
move_anchor
),
cmd!(
"path.setAnchors",
"Set Anchors",
@@ -100,11 +110,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Reshape",
[],
None,
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius (default: half the subpath's size)",
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius, measured along the path (default: half the subpath's size)",
has_doc,
reshape
),
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath?, segment, t: 0..1}", has_doc, insert_anchor),
cmd!(
"path.cutAtAnchors",
"Cut Path at Selected Anchor Points",
@@ -215,7 +225,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
@@ -229,6 +239,22 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point
/// tool's Shift-drag).
fn move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?;
let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))? as usize;
let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?);
s.edit("Move", |d, _| {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
*a = a.transform(vectorcraft_geom::Affine::translate(pos - a.p));
Ok(())
})?;
ok()
}
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
@@ -601,7 +627,6 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
if gone.is_empty() {
return vec![sp];
}
let keep = |anchors: Vec<Anchor>| (anchors.len() >= 2).then(|| SubPath::new(anchors, false));
let mut runs: Vec<Vec<Anchor>> = vec![];
let mut run = vec![];
for (i, anchor) in sp.anchors.into_iter().enumerate() {
@@ -629,7 +654,7 @@ fn without_anchors(sp: SubPath, removed: &BTreeSet<usize>) -> Vec<SubPath> {
} else if !run.is_empty() {
runs.push(run);
}
runs.into_iter().filter_map(keep).collect()
runs.into_iter().filter_map(open_run).collect()
}
/// Remove connections, splitting open/closed contours into their surviving open runs.
@@ -680,18 +705,27 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
Some((i, dist)) if dist <= tol => i,
_ => sp.insert_anchor(seg, t),
};
let origin = sp.anchors[grabbed].p;
// Each anchor's distance along the path from the grabbed one, the shorter way round a
// closed path: the lengths are measured once, as a path can have many anchors.
let pos = sp.anchor_arc_positions();
let total = pos.last().copied().unwrap_or(0.0);
// The default reach is the subpath's size, as before, now measured along it.
let r = radius.unwrap_or_else(|| {
let mut bp = vectorcraft_geom::BezPath::new();
sp.to_bezpath_into(&mut bp);
let b = vectorcraft_geom::Shape::bounding_box(&bp);
(b.width().hypot(b.height()) / 2.0).max(1.0)
});
let from = pos.get(grabbed).copied().unwrap_or(0.0);
let closed = sp.closed;
for (i, a) in sp.anchors.iter_mut().enumerate() {
let w = if i == grabbed {
1.0
} else {
let q = (a.p.distance(origin) / r).min(1.0);
let d = (pos.get(i).copied().unwrap_or(f64::INFINITY) - from).abs();
let d = if closed { d.min(total - d) } else { d };
// A distance or radius that isn't finite gives NaN here, which `min` reads as 1: no move.
let q = (d / r).min(1.0);
(1.0 - q * q).powi(2)
};
let v = delta * w;
@@ -706,7 +740,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0);
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
let idx = s.edit("Add Anchor Point", |d, _| {
+32
View File
@@ -89,6 +89,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
anchors_many
),
cmd!(
"select.color",
"Select by Color",
[],
None,
"{fill?: colour, stroke?: colour (as paint.setFill's color; at least one)} select the objects whose fill (and stroke) reads as that colour in #rrggbb, as document.inspect reports colours, whatever its swatch or tint; with both, the objects that match both. No object needs to be selected first; Select > Reselect repeats it → {count}",
has_doc,
select_color
),
cmd!(
"select.same.fillColor",
"Fill Color",
@@ -528,6 +537,29 @@ fn stroke_weight(n: &Node) -> f64 {
if paint.is_none() { 0.0 } else { width }
}
/// Select by Color: the objects whose fill and stroke read as the colours given, compared as the
/// `#rrggbb` `document.inspect` reports, so a script can pick art by colour without an object id.
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "select.color";
let hex = |key: &str| match p.get(key).filter(|v| !v.is_null()) {
None => Ok(None),
Some(v) => color_value(v).map(|c| Some(c.to_hex())).ok_or_else(|| bad(C, format!("`{key}`: not a colour: {v}"))),
};
let (fill, stroke) = (hex("fill")?, hex("stroke")?);
if fill.is_none() && stroke.is_none() {
return Err(bad(C, "give `fill`, `stroke` or both"));
}
let reads_as = |paint: Paint, hex: Option<&str>| hex.is_none_or(|h| paint.color().is_some_and(|c| c.to_hex() == h));
select_where(s, C, p, |d, n| paint_reported(d, n) && reads_as(fill_of(n), fill.as_deref()) && reads_as(stroke_of(n).0, stroke.as_deref()))
}
/// Does `document.inspect` report a fill and stroke for `n`? A compound path's are its own, though
/// it holds its members, and its members report none.
fn paint_reported(d: &Document, n: &Node) -> bool {
let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. });
compound(n) || (!n.is_container() && !d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(compound))
}
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
let st = s.doc()?;
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
+12 -4
View File
@@ -459,7 +459,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
const C: &str = "dataset.select";
let (applied, skipped, mut typed) = s.edit("Apply Data Set", |d, sel| {
let (applied, skipped, mut typed, bound) = s.edit("Apply Data Set", |d, sel| {
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
// One object may be driven by several variables; walk them in definition order, so two
// text variables on the same object land the later one's value and both are counted.
@@ -467,6 +467,8 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
for v in &d.variables.variables {
pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone())));
}
// Whether an object is bound to a variable that this row has a value for (the reply's `warning`).
let bound = pairs.iter().any(|(_, var)| ds.values.contains_key(var));
let mut applied = 0usize;
let mut skipped = 0usize;
// Collects the type objects whose text the row rewrote, for the reply's `overflow`.
@@ -514,12 +516,18 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
d.variables.active_dataset = Some(name.to_string());
// Every selected object left hidden or locked, such as one the row hid, is deselected.
sel.deselect_uneditable(d);
Ok((applied, skipped, typed))
Ok((applied, skipped, typed, bound))
})?;
// Two text variables on one object rewrite it twice; it is named once.
typed.sort_unstable();
typed.dedup();
Ok(super::typecmd::with_overflow(s, &typed, json!({"name": name, "applied": applied, "skipped": skipped})))
let mut out = super::typecmd::with_overflow(s, &typed, json!({"name": name, "applied": applied, "skipped": skipped}));
// A row with no value for any bound variable applies nothing. The reply then carries a
// `warning`, which the app shows in its status bar.
if !bound {
out["warning"] = json!(format!("nothing in data set {name} was applied: no object is bound to a variable it has a value for"));
}
Ok(out)
}
fn dataset_select(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1037,7 +1045,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Select Data Set",
["Window", "Variables"],
None,
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits)}",
"{name} apply a dataset in one undo step, deselecting every selected object left hidden or locked (the key object too) → {applied, skipped, overflow?: [id…] (area type and type on a path whose text it rewrote and that no longer fits; left out when all fits), warning? (when no object is bound to a variable that the dataset has a value for, so nothing was applied)}",
has_doc,
dataset_select
),
+3 -2
View File
@@ -87,10 +87,11 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.menu.wand))
}
/// The fill an object shows: type's comes from its characters (the first run speaks for all).
/// The fill an object shows: type's comes from its characters (the first run speaks for all; type
/// without any, the default character fill, as `document.inspect` reports it).
pub(crate) fn fill_of(n: &Node) -> Paint {
match &n.kind {
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(),
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_else(|| vectorcraft_doc::CharStyle::default().fill),
_ => n.appearance.fill_paint(),
}
}
+6 -1
View File
@@ -1702,14 +1702,17 @@ impl Session {
let before = st.doc.clone();
let before_sel = st.selection.clone();
let doc = Arc::make_mut(&mut st.doc);
// Dimensions follow the art they measure.
// Objects attached to a path follow it, and dimensions follow the art they measure.
let result = match f(doc, &mut st.selection).and_then(|v| {
cmd::objects_on_path::refresh(&before, doc);
cmd::dimension::refresh(&before, doc)?;
Ok(v)
}) {
Ok(v) => match cmd::shaper::refresh(&before, Arc::make_mut(&mut st.doc)) {
Err(e) => Err(e),
Ok(()) => {
// Attached Shaper groups whose art was rebuilt go back on their path.
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
// Inline graphics in text follow their symbols.
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
// Text Wrap: area type follows its wrap objects; then threads re-flow.
@@ -2185,6 +2188,8 @@ mod tests_newart;
#[cfg(test)]
mod tests_newdoc;
#[cfg(test)]
mod tests_objects_on_path;
#[cfg(test)]
mod tests_objexpand;
#[cfg(test)]
mod tests_opacitymask;
+46
View File
@@ -2343,6 +2343,29 @@ fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
assert!(pts.iter().filter(|p| (p.y - 100.0).abs() < 1e-9).count() == 2);
}
#[test]
fn reshape_falloff_follows_arc_length_on_open_paths() {
let mut s = session();
let id = NodeId(
s.execute(
"path.create",
&json!({
"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 200, "y": 0}, {"x": 300, "y": 0}],
"closed": false
}),
)
.unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("path.reshape", &json!({"id": id.0, "x": 300.0, "y": 0.0, "dx": 0.0, "dy": 100.0})).unwrap();
let pts: Vec<_> = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect();
assert!((pts[3].y - 100.0).abs() < 1e-6, "grabbed end moves full delta: {pts:?}");
assert!(pts[0].y.abs() < 1e-6, "far end stays put: {pts:?}");
assert!(pts[2].y > 1.0 && pts[2].y < 100.0, "mid-anchor moves partially: {pts:?}");
assert!(pts[1].y < pts[2].y, "nearer anchors move more: {pts:?}");
}
#[test]
fn graph_tool_drag_creates_a_graph_and_asks_for_data() {
let mut s = session();
@@ -2429,6 +2452,29 @@ fn object_mosaic_refuses_columns_and_rows_that_are_not_whole_numbers() {
assert_eq!(r["tiles"], json!(2));
}
/// A new width or height, or a spacing, given as text or as another value that is not a number is an error naming the
/// key, and no mosaic is made.
#[test]
fn object_mosaic_refuses_a_size_or_spacing_given_as_text() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 50.0);
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let before = undo(&s);
for (p, msg) in [
(json!({"width": "200"}), "`width` must be a number, not \"200\""),
(json!({"columns": 2, "height": "50 pt"}), "`height` must be a number, not \"50 pt\""),
(json!({"spacingX": "4"}), "`spacingX` must be a number, not \"4\""),
(json!({"spacingY": [4]}), "`spacingY` must be a number, not [4]"),
] {
let r = s.execute("object.createObjectMosaic", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
assert_eq!(undo(&s), before, "{p}");
}
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "width": 200, "spacingX": null})).unwrap();
assert_eq!(r["tiles"], json!(2));
}
#[test]
fn snap_to_pixel_rounds_drawing_and_moves() {
let mut s = session();
+219
View File
@@ -130,6 +130,62 @@ fn pencil_continues_selected_open_path() {
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 100.0));
}
/// Edit Selected Paths in the middle of a path (#2850): a Pencil stroke that starts and ends on a
/// selected path replaces the section between, drawn either way; the path stays one path with
/// its ends, and is one undo step.
#[test]
fn pencil_redraws_the_section_of_a_selected_path_between_its_ends() {
let bump = [(100.0, 101.0), (125.0, 70.0), (150.0, 60.0), (175.0, 70.0), (200.0, 101.0)];
for reversed in [false, true] {
let mut s = session();
let id = line(&mut s, 0.0, 100.0, 300.0, 100.0);
let mut pts = bump.to_vec();
if reversed {
pts.reverse();
}
let undo = undo_steps(&s);
drag(&mut s, "pencil", &pts, Mods::default());
assert_eq!(paths(&s).len(), 1, "no new object");
assert_eq!(undo_steps(&s), undo + 1);
let sp = &path(&s, id).subpaths[0];
assert!(!sp.closed);
let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
assert!(near(ps[0], Point::new(0.0, 100.0)) && near(*ps.last().unwrap(), Point::new(300.0, 100.0)), "{ps:?}");
// The section starts and ends on the line, where the stroke met it, and rises between.
let on = |x: f64| ps.iter().any(|p| (p.x - x).abs() < 1.5 && (p.y - 100.0).abs() < 1e-6);
assert!(on(100.0) && on(200.0), "{reversed}: {ps:?}");
let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0;
assert!(top < 75.0, "the bump: top {top}");
assert!(ps.windows(2).all(|w| w[0].x <= w[1].x + 1.0), "in order along the line: {ps:?}");
}
}
/// On a closed path the stroke replaces the shorter way between where it starts and ends, and the
/// path stays closed; a stroke that doesn't end on the path draws a new one.
#[test]
fn pencil_redraws_the_shorter_way_round_a_closed_path() {
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 200.0, 100.0);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("path.freehand", &json!({"points": [[50, 1], [80, -25], [100, -30], [120, -25], [150, 1]], "redraw": {"id": id.0, "within": 4}}))
.unwrap();
assert_eq!(paths(&s).len(), 1);
let sp = &path(&s, id).subpaths[0];
assert!(sp.closed);
let ps: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
for corner in [(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0)] {
assert!(ps.iter().any(|p| near(*p, Point::new(corner.0, corner.1))), "{corner:?} kept: {ps:?}");
}
let top = kurbo::Shape::bounding_box(&path(&s, id).to_bezpath()).y0;
assert!(top < -20.0, "the dip: top {top}");
// Not ending on the path: a new path, the rectangle as it was.
let before = path(&s, id);
s.execute("path.freehand", &json!({"points": [[50, 100], [80, 150], [120, 160]], "redraw": {"id": id.0, "within": 4}})).unwrap();
assert_eq!(paths(&s).len(), 2);
assert_eq!(path(&s, id), before);
assert!(s.execute("path.freehand", &json!({"points": [[0, 0], [1, 1]], "redraw": {"id": id.0, "within": -1}})).is_err());
}
/// With Keep selected off, the path a stroke draws or continues is not selected afterwards, the
/// rest of the selection stays, and the stroke is one undo step.
#[test]
@@ -811,6 +867,149 @@ fn draw2_commands_reject_bad_params() {
}
}
/// A path of two open subpaths, each of three anchors from x 100 to x 300 with a middle anchor that
/// has two handles: subpath 0 at y 100, subpath 1 at y 300.
fn two_subpaths(s: &mut Session) -> NodeId {
let id = curve(s);
let sub = |y: f64| {
let anchors = json!([
{"x": 100, "y": y, "out": [130, y - 30.0]},
{"x": 200, "y": y, "in": [170, y - 30.0], "out": [230, y - 30.0]},
{"x": 300, "y": y, "in": [270, y - 30.0]},
]);
json!({"anchors": anchors, "closed": false})
};
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [sub(100.0), sub(300.0)]})).unwrap();
id
}
/// Runs `cmd` with `p` and `key` set to each of 1.5, -1 and "1": each call fails with an error that
/// names `key`, and the document and the undo history stay as they were.
fn refuses_an_index_that_is_not_whole(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) {
for v in [json!(1.5), json!(-1), json!("1")] {
let mut p = p.clone();
p[key] = v;
let before = s.doc().unwrap().doc.clone();
let undo = undo_steps(s);
let Err(e) = s.execute(cmd, &p) else { panic!("{cmd} {p} succeeded") };
assert!(e.to_string().contains(&format!("`{key}`")), "{cmd} {p}: {e}");
assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document");
assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step");
}
}
/// Runs `cmd` with `p` on path `id` (made by [`two_subpaths`]) with `subpath: 1`, without `subpath`
/// and with `subpath: null`, undoing each: the first changes subpath 1 alone, and the others change
/// subpath 0 alone.
fn edits_the_subpath_given(s: &mut Session, cmd: &str, p: &serde_json::Value, id: NodeId) {
let before = path(s, id).subpaths;
for (si, p) in [(1, with(p, json!({"subpath": 1}))), (0, p.clone()), (0, with(p, json!({"subpath": null})))] {
s.execute(cmd, &p).unwrap();
let after = path(s, id).subpaths;
assert_eq!(after.len(), 2, "{cmd} {p}");
assert!(after[si] != before[si] && after[1 - si] == before[1 - si], "{cmd} {p}: {after:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(s, id).subpaths, before, "{cmd} {p}: undo");
}
}
#[test]
fn curvature_edit_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "op": "move", "anchor": 1, "x": 200, "y": 150});
refuses_an_index_that_is_not_whole(&mut s, "path.curvatureEdit", &p, "subpath");
edits_the_subpath_given(&mut s, "path.curvatureEdit", &p, id);
}
#[test]
fn remove_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 1});
refuses_an_index_that_is_not_whole(&mut s, "path.removeAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.removeAnchor", &p, id);
}
#[test]
fn convert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 1, "to": "corner"});
refuses_an_index_that_is_not_whole(&mut s, "path.convertAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.convertAnchor", &p, id);
}
#[test]
fn reshape_segment_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20});
refuses_an_index_that_is_not_whole(&mut s, "path.reshapeSegment", &p, "subpath");
edits_the_subpath_given(&mut s, "path.reshapeSegment", &p, id);
}
#[test]
fn split_refuses_a_subpath_anchor_or_segment_that_is_not_a_whole_number() {
// A bad `anchor` beside a good `segment` is refused, and so is a bad `segment` beside a good
// `anchor`.
let mut s = session();
let id = two_subpaths(&mut s);
let at_anchor = json!({"id": id.0, "anchor": 1});
let at_segment = json!({"id": id.0, "segment": 0, "t": 0.5});
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "subpath");
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "subpath");
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_segment, "anchor");
refuses_an_index_that_is_not_whole(&mut s, "path.split", &at_anchor, "segment");
let before = path(&s, id).subpaths;
for (si, p) in [(1, with(&at_anchor, json!({"subpath": 1}))), (0, at_anchor.clone()), (0, with(&at_anchor, json!({"subpath": null})))] {
s.execute("path.split", &p).unwrap();
let after = path(&s, id).subpaths;
// The subpath split becomes two of two anchors each, in its place.
let kept = if si == 1 { &after[0] } else { &after[2] };
assert!(after.len() == 3 && after[si].anchors.len() == 2 && after[si + 1].anchors.len() == 2, "{p}: {after:?}");
assert_eq!(kept, &before[1 - si], "{p}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s, id).subpaths, before, "{p}: undo");
}
}
#[test]
fn set_handle_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 0, "which": "out", "x": 140, "y": 250});
refuses_an_index_that_is_not_whole(&mut s, "path.setHandle", &p, "subpath");
edits_the_subpath_given(&mut s, "path.setHandle", &p, id);
}
#[test]
fn move_anchor_takes_its_handles_along_and_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "anchor": 0, "x": 140, "y": 250});
refuses_an_index_that_is_not_whole(&mut s, "path.moveAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.moveAnchor", &p, id);
// The anchor lands on (x, y) and its handles keep their offsets from it.
s.execute("path.setHandle", &json!({"id": id.0, "anchor": 0, "which": "out", "x": 30, "y": 40})).unwrap();
let a = path(&s, id).subpaths[0].anchors[0];
s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 0, "x": a.p.x + 15.0, "y": a.p.y - 5.0})).unwrap();
let b = path(&s, id).subpaths[0].anchors[0];
assert_eq!(b.p, a.p + vectorcraft_geom::Vec2::new(15.0, -5.0));
assert_eq!(b.h_out - b.p, a.h_out - a.p);
assert_eq!(b.h_in - b.p, a.h_in - a.p);
assert!(s.execute("path.moveAnchor", &json!({"id": id.0, "anchor": 99, "x": 0, "y": 0})).is_err());
}
#[test]
fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() {
let mut s = session();
let id = two_subpaths(&mut s);
let p = json!({"id": id.0, "segment": 0, "t": 0.5});
refuses_an_index_that_is_not_whole(&mut s, "path.insertAnchor", &p, "subpath");
edits_the_subpath_given(&mut s, "path.insertAnchor", &p, id);
}
#[test]
fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
// `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and
@@ -1787,6 +1986,26 @@ fn open_anchor_delete_preserves_curves_appearance_and_history() {
assert_eq!(s.doc().unwrap().doc, after);
}
#[test]
fn closed_anchor_delete_then_join_closes_with_a_line() {
let mut s = session();
let data = "M100 100C120 60 180 60 200 100C220 140 280 140 300 100C320 60 380 60 400 100C420 140 480 140 500 100C500 200 100 200 100 100Z";
let id = NodeId(s.execute("path.create", &json!({"d": data})).unwrap()["id"].as_u64().unwrap());
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]]})).unwrap();
s.execute("path.deleteAnchors", &json!({})).unwrap();
let result = path(&s, id);
assert_eq!(result.subpaths.len(), 1);
let sp = &result.subpaths[0];
assert!(!sp.closed);
assert!(!sp.anchors.first().unwrap().has_in() && !sp.anchors.last().unwrap().has_out(), "dangling handles are cleared");
s.execute("path.join", &json!({"ids": [id.0]})).unwrap();
let joined = path(&s, id);
let sp = &joined.subpaths[0];
assert!(sp.closed);
let closing = sp.segment(sp.segment_count() - 1);
assert!(closing.p1 == closing.p0 && closing.p2 == closing.p3, "closing segment is a line: {closing:?}");
}
#[test]
fn open_anchor_delete_does_not_change_explicit_remove_anchors() {
for command in ["path.removeAnchors", "path.removeAnchor"] {
+24 -1
View File
@@ -62,10 +62,14 @@ fn clicking_without_typing_leaves_no_text_and_no_history() {
click(&mut s, 150.0, 100.0);
s.select_tool("rectangle", v).unwrap();
assert!(texts(&s).is_empty());
// Click in two places: the first is gone when the second is placed.
// Click in two places: the second click discards the first type and places none (#2871); a
// third places new type.
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 300.0);
click(&mut s, 400.0, 300.0);
assert!(texts(&s).is_empty());
assert!(!s.tool_wants_text());
click(&mut s, 400.0, 300.0);
assert_eq!(texts(&s).len(), 1);
s.select_tool("selection", v).unwrap();
assert!(texts(&s).is_empty());
@@ -95,6 +99,25 @@ fn typed_text_stays_and_typing_it_away_leaves_nothing() {
assert_eq!(undo_labels(&s), kept);
}
/// A click on blank canvas while type is being edited ends the editing and makes no new type; the
/// Type tool stays, and the next click places point type (#2871, #2854).
#[test]
fn a_click_away_from_typed_text_ends_editing_without_new_type() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 100.0, 100.0);
s.tool_text("Hi", v).unwrap();
click(&mut s, 400.0, 300.0);
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hi"]);
assert_eq!(s.tool_id(), "type");
assert!(!s.tool_wants_text());
assert_eq!(undo_labels(&s), ["Type", "Typing"]);
click(&mut s, 400.0, 300.0);
assert_eq!(texts(&s).len(), 2);
assert!(s.tool_wants_text());
}
#[test]
fn frames_stay_when_left_empty() {
let mut s = session();
+2 -2
View File
@@ -175,9 +175,9 @@ fn assert_dated(bytes: &[u8], created: std::ops::RangeInclusive<i64>, modified:
/// Run `f` → (its result, the time of writing: the Unix seconds before and after it).
fn written<T>(f: impl FnOnce() -> T) -> (T, std::ops::RangeInclusive<i64>) {
let before = vectorcraft_doc::metadata::now_unix().unwrap();
let before = vectorcraft_doc::metadata::output_time().unwrap();
let out = f();
(out, before..=vectorcraft_doc::metadata::now_unix().unwrap())
(out, before..=vectorcraft_doc::metadata::output_time().unwrap())
}
#[test]
+13 -7
View File
@@ -371,7 +371,7 @@ fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() {
assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}");
}
/// #2692's files: an image clipped to a circle inside a circle's stroke, and an image with
/// #2692's file, an image clipped to a circle inside a circle's stroke, and #2830's, an image with
/// see-through pixels (which the save flattens into an image inside a clipping group), each with
/// an orange square on the next layer.
const CLIPPED_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="200" viewBox="0 0 400 200">
@@ -389,14 +389,16 @@ const SEE_THROUGH_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmln
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
</svg>"##;
/// #2692: the image inside a clipping group, clipped in the file or flattened by the save, is
/// written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space inside
/// each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`. The next
/// #2692 and #2830: the image inside a clipping group, clipped in the file or flattened by the
/// save, is written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space
/// inside each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`.
/// A line `F`, which the specification's examples do not show, follows `%AI5_EndRaster`. The next
/// layer follows with the square's exact lines, and the file opens again with both layers, the
/// clipping group, the image and the square.
/// clipping group, the image, the square and, besides the clipping path, only the paths the SVG
/// holds.
#[test]
fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
for (svg, layer) in [(CLIPPED_IMAGE, "clipped-image"), (SEE_THROUGH_IMAGE, "alpha-image")] {
for (svg, layer, paths) in [(CLIPPED_IMAGE, "clipped-image", 2), (SEE_THROUGH_IMAGE, "alpha-image", 1)] {
let mut s = Session::new();
s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
@@ -411,7 +413,8 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
let end = at + lines[at..].iter().position(|l| *l == "%AI5_EndRaster").unwrap();
let (last, full) = lines[at + 1..end].split_last().unwrap();
assert!(full.iter().all(|l| l.len() == 61) && (2..=61).contains(&last.len()), "{layer}");
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
assert_eq!(lines[end + 1], "F", "{layer}");
assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\nLB\n%AI5_EndLayer--\n"), "{text}");
let square = "%AI5_BeginLayer\n1 1 1 1 0 0 1 255 79 79 Lb\n(above) Ln\n0 A\n0.9686 0.5804 0.1137 Xa\n0 O\n0 XR\n\
220 180 m\n380 180 L\n380 20 L\n220 20 L\n220 180 L\nf\nLB\n%AI5_EndLayer--\n%%PageTrailer\n";
assert!(text.contains(square), "{text}");
@@ -419,6 +422,9 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() {
let d = &s.doc().unwrap().doc;
assert_eq!(layer_names(d), [layer, "above"]);
assert_eq!(images(d), (1, 1), "{layer}");
let mut drawn = 0;
d.walk(|n| drawn += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: false, .. })));
assert_eq!(drawn, paths, "{layer}");
assert_eq!(colour_at(d, 300.0, 100.0), [247, 148, 29], "{layer}");
assert_ne!(colour_at(d, 100.0, 100.0), [255, 255, 255], "{layer}");
}
+80
View File
@@ -88,6 +88,86 @@ fn linked_files_on_other_computers_are_never_read() {
assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys());
}
/// The final path Windows gives a folder of the drive `Z:` mapped to `\\server\share`. The
/// share's root folder has no trailing separator (`\\?\UNC\server\share`).
fn on_share(folder: &str) -> Option<std::path::PathBuf> {
let rest = folder.get(2..)?.trim_start_matches(['\\', '/']).replace('/', "\\");
let root = r"\\?\UNC\server\share";
Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() })
}
#[test]
fn a_document_on_a_mapped_drive_is_on_the_share_behind_it() {
use crate::cmd::fileio::share_of;
// craft-docpath refuses a link on the share when the document is given by its drive letter.
let link = r"\\server\share\img\logo.png";
assert_eq!(craft_docpath::resolve(link, Some(std::path::Path::new(r"Z:\jobs\doc.vectorcraft"))), Err(craft_docpath::Refused::Network));
assert!(craft_docpath::resolve(link, Some(std::path::Path::new(r"\\server\share\jobs\doc.vectorcraft"))).is_ok());
// The document's path on the share, from its folder's final path.
let mut asked = vec![];
let mut ask = |folder: &str| {
asked.push(folder.to_string());
on_share(folder)
};
assert_eq!(share_of(r"Z:\jobs\doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\jobs\doc.vectorcraft"));
assert_eq!(share_of(r"Z:\doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\doc.vectorcraft"));
assert_eq!(share_of("Z:/jobs/doc.vectorcraft", &mut ask).as_deref(), Some(r"\\server\share\jobs\doc.vectorcraft"));
assert_eq!(asked, [r"Z:\jobs", r"Z:\", "Z:/jobs"]);
let unc = |p: &'static str| move |_: &str| Some(std::path::PathBuf::from(p));
assert_eq!(share_of(r"Z:\doc.vectorcraft", unc(r"\\?\unc\server\share\")).as_deref(), Some(r"\\server\share\doc.vectorcraft"));
// A folder on a local volume (also behind a `subst` drive), a folder that can't be opened,
// and a final path without a share name give no path on a share.
assert_eq!(share_of(r"C:\art\doc.vectorcraft", unc(r"\\?\C:\art")), None);
assert_eq!(share_of(r"S:\doc.vectorcraft", unc(r"\\?\D:\real")), None);
assert_eq!(share_of(r"Z:\gone\doc.vectorcraft", |_| None), None);
assert_eq!(share_of(r"Z:\doc.vectorcraft", unc(r"\\?\UNC\server")), None);
// A document given by its share, or not by a drive, is not looked up.
for doc in [
r"\\server\share\doc.vectorcraft",
r"\\?\Z:\doc.vectorcraft",
"/Users/a/doc.vectorcraft",
"doc.vectorcraft",
"Z:doc.vectorcraft",
r"Z:\jobs\",
] {
assert_eq!(share_of(doc, |f: &str| panic!("{doc}: looked up {f}")), None, "{doc}");
}
}
#[cfg(windows)]
#[test]
fn links_on_a_mapped_drives_share_are_followed() {
use crate::cmd::fileio::{document_path_with, share_of};
let doc = Some(r"Z:\jobs\doc.vectorcraft");
let share = |d: &str| share_of(d, on_share);
// A link on the share behind the drive is followed; one on another share still isn't.
assert_eq!(document_path_with(r"\\server\share\img\logo.png", doc, share).as_deref(), Ok(r"\\server\share\img\logo.png"));
assert_eq!(document_path_with(r"\\SERVER\Share\logo.png", doc, share).as_deref(), Ok(r"\\SERVER\Share\logo.png"));
assert_eq!(document_path_with(r"\\other\share\logo.png", doc, share), Err(craft_docpath::Refused::Network));
assert_eq!(document_path_with("file://server/share/logo.png", doc, share), Err(craft_docpath::Refused::Network));
// On a local volume, the link stays refused.
assert_eq!(document_path_with(r"\\server\share\logo.png", doc, |_| None), Err(craft_docpath::Refused::Network));
// Links that were followed resolve as before, without looking the share up.
let unasked = |d: &str| -> Option<String> { panic!("looked up {d}") };
assert_eq!(document_path_with(r"img\logo.png", doc, unasked).as_deref(), Ok(r"Z:\jobs\img\logo.png"));
assert_eq!(document_path_with(r"D:\art\logo.png", doc, unasked).as_deref(), Ok(r"D:\art\logo.png"));
}
#[test]
fn a_document_off_a_mapped_drive_keeps_its_path() {
use crate::cmd::fileio::share_path;
let dir = Folder::new("docpath-share");
// A folder on this computer's own disk, and one that doesn't exist.
assert_eq!(share_path(&dir.file("doc.vectorcraft")), None);
assert_eq!(share_path(&dir.file("gone/doc.vectorcraft")), None);
// Elsewhere than Windows, no document is looked up.
if cfg!(not(windows)) {
assert_eq!(share_path(r"Z:\jobs\doc.vectorcraft"), None);
}
}
#[test]
fn svg_images_on_other_computers_are_never_read() {
let dir = Folder::new("docpath-svg");
+36
View File
@@ -157,6 +157,42 @@ fn a_linked_photoshop_document_places_and_updates() {
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
}
/// An uncompressed 24-bit `w`×`h` Targa file of one color.
fn targa(w: u16, h: u16, rgb: [u8; 3]) -> Vec<u8> {
let mut f = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0];
f.extend(w.to_le_bytes());
f.extend(h.to_le_bytes());
f.extend([24, 0]);
let [r, g, b] = rgb;
f.extend(std::iter::repeat_n([b, g, r], usize::from(w) * usize::from(h)).flatten());
f
}
/// A Targa file placed linked shows its pixels and updates when it changes, and a link can be
/// relinked to one.
#[test]
fn a_linked_targa_file_places_updates_and_relinks() {
let dir = Folder::new("tga");
let pic = dir.file("art/photo.tga");
write(&pic, &targa(600, 300, RED));
let mut s = session();
let id = place(&mut s, &pic);
assert_eq!(image(&s, id).link.unwrap().path, pic);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
write(&pic, &targa(400, 400, BLUE));
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [id.0], "missing": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height), (400, 400));
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
let other = dir.file("other/red.vst");
write(&other, &targa(300, 100, RED));
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap();
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
let im = image(&s, id);
assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other));
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
#[test]
fn the_relative_path_finds_the_file_after_the_folder_moves() {
let a = Folder::new("move-a");
+154
View File
@@ -83,6 +83,13 @@ fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than a number.
fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) {
let r = s.execute(cmd, p);
let want = format!("`{key}` must be a number, not {}", p[key]);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}");
}
// ---------- Object → Lock / Hide ----------
#[test]
@@ -399,6 +406,29 @@ fn transform_each_refuses_a_reference_or_seed_that_is_not_a_whole_number() {
assert_eq!(bounds(&s, a), Rect::new(0.0, 0.0, 50.0, 50.0));
}
/// Transform Each refuses a scale, move or rotation given as text or as another value that is not a
/// number, and the object stays as it was; numbers for the scales and the horizontal move transform it.
#[test]
fn transform_each_refuses_a_number_given_as_text() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
sel(&mut s, &[a]);
let before = (undo_len(&s), bounds(&s, a));
for (key, p) in [
("scaleH", json!({"scaleH": "50"})),
("scaleV", json!({"scaleH": 50, "scaleV": "50%"})),
("moveH", json!({"moveH": "10"})),
("moveV", json!({"moveH": 10, "moveV": [5]})),
("rotate", json!({"rotate": "45"})),
("rotate", json!({"moveH": 10, "rotate": true})),
] {
refuses_non_number(&mut s, "object.transformEach", &p, key);
assert_eq!((undo_len(&s), bounds(&s, a)), before, "{p}");
}
s.execute("object.transformEach", &json!({"scaleH": 50, "scaleV": 50, "moveH": 10, "moveV": null})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(35.0, 25.0, 85.0, 75.0));
}
/// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as
/// Effect › Distort › Transform and Illustrator's Transform Each checkboxes).
#[test]
@@ -571,6 +601,22 @@ fn rasterize_replaces_selection_with_image() {
assert_eq!(selected(&s), vec![img]);
}
/// Rasterize refuses a resolution given as text or as another value that is not a number, and the
/// selected art stays as it was.
#[test]
fn rasterize_refuses_a_ppi_given_as_text() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
sel(&mut s, &[a]);
let before = (undo_len(&s), node(&s, a));
for p in [json!({"ppi": "144"}), json!({"ppi": "300 ppi", "padding": 0}), json!({"ppi": [72]})] {
refuses_non_number(&mut s, "object.rasterize", &p, "ppi");
assert_eq!((undo_len(&s), node(&s, a)), before, "{p}");
}
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 144, "padding": 0})).unwrap());
assert!(matches!(node(&s, img).kind, NodeKind::Image(_)));
}
#[test]
fn rasterize_says_when_an_image_could_not_be_decoded() {
let mut s = session();
@@ -608,6 +654,25 @@ fn crop_image_to_rect() {
assert!(s.execute("object.cropImage", &json!({})).is_err());
}
/// Crop Image refuses a `rect` that is not an array, such as text, and the image stays as it was; a
/// null `rect` crops to the artboard as a missing one does.
#[test]
fn crop_image_refuses_a_rect_given_as_text() {
let mut s = session();
let a = rect(&mut s, -20.0, -20.0, 100.0, 100.0);
sel(&mut s, &[a]);
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
let before = (undo_len(&s), node(&s, img));
for p in [json!({"rect": "0, 0, 20, 20"}), json!({"rect": {"x": 0, "y": 0, "width": 20, "height": 20}}), json!({"rect": 20})] {
let r = s.execute("object.cropImage", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "rect must be [x, y, width, height]"), "{p}: {r:?}");
assert_eq!((undo_len(&s), node(&s, img)), before, "{p}");
}
s.execute("object.cropImage", &json!({"rect": null})).unwrap();
let b = bounds(&s, img);
assert!(close(b.x0, 0.0) && close(b.y0, 0.0), "{b:?}");
}
#[test]
fn crop_image_disabled_without_image() {
let mut s = session();
@@ -656,6 +721,30 @@ fn trim_marks_around_selection() {
assert!(close(b.x0, vb.x0 - 30.0) && close(b.y1, vb.y1 + 30.0), "{b:?} {vb:?}");
}
/// Create Trim Marks refuses an offset, length, bleed or weight given as text or as another value
/// that is not a number, around the selection and around the artboards, and makes no marks.
#[test]
fn trim_marks_refuse_a_number_given_as_text() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
let calls = [
("offset", json!({"offset": "10"})),
("length", json!({"length": "20 pt"})),
("bleed", json!({"style": "japanese", "bleed": "3"})),
("weight", json!({"weight": true})),
("offset", json!({"allArtboards": true, "offset": {"value": 10}})),
];
for ids in [vec![a], vec![]] {
sel(&mut s, &ids);
let before = (undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len());
for (key, p) in &calls {
refuses_non_number(&mut s, "object.createTrimMarks", p, key);
assert_eq!((undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len()), before, "{p}");
}
}
s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20, "bleed": null})).unwrap();
}
// ---------- Convert to Shape ----------
#[test]
@@ -791,6 +880,22 @@ fn rearrange_refuses_columns_that_are_not_a_whole_number() {
assert_eq!((r["rows"].clone(), r["columns"].clone()), (json!(2), json!(1)));
}
/// Rearrange All Artboards refuses a spacing given as text or as another value that is not a number,
/// and the artboards stay where they were.
#[test]
fn rearrange_refuses_a_spacing_given_as_text() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let before = (boards(&s), undo_len(&s));
for p in [json!({"spacing": "50"}), json!({"columns": 1, "spacing": "50 pt"}), json!({"layout": "row", "spacing": false})] {
refuses_non_number(&mut s, "artboard.rearrange", &p, "spacing");
assert_eq!((boards(&s), undo_len(&s)), before, "{p}");
}
s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50})).unwrap();
assert_eq!(boards(&s)[1], Rect::new(0.0, 650.0, 400.0, 950.0));
}
/// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an
/// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from
/// outside or on no artboard doesn't count, and an artboard with no art stays as it is.
@@ -1026,6 +1131,55 @@ fn select_same_paint_reads_type_characters() {
assert_eq!(same(&mut s, t1, "select.same.fillAndStroke"), vec![t1, r]);
}
/// Select by Color needs no object to start from: it matches fills and strokes as
/// `document.inspect` reports them, `#rrggbb`.
#[test]
fn select_color_matches_fill_and_stroke_without_a_selection() {
let mut s = session();
let [a, b, c] = [0.0, 20.0, 40.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0));
let t = text(&mut s, "a");
fill(&mut s, a, "#000000");
fill(&mut s, t, "#000000");
// A grey as a PDF stores it reads as #fefefe.
s.execute("paint.setFill", &json!({"ids": [b.0], "color": [0.996, 0.996, 0.996]})).unwrap();
fill(&mut s, c, "#ff0000");
s.execute("paint.setStroke", &json!({"ids": [a.0, c.0], "color": "#0000ff"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let pick = |s: &mut Session, p: Value| {
s.execute("select.color", &p).unwrap();
let mut v = selected(s);
v.sort();
v
};
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t]);
assert_eq!(pick(&mut s, json!({"fill": "#fefefe"})), vec![b]);
assert_eq!(pick(&mut s, json!({"stroke": "#0000ff"})), vec![a, c]);
assert_eq!(pick(&mut s, json!({"fill": "#000000", "stroke": "#0000ff"})), vec![a]);
// Select > Reselect repeats it.
s.execute("select.none", &json!({})).unwrap();
s.execute("select.reselect", &json!({})).unwrap();
assert_eq!(selected(&s), vec![a]);
// A compound path reads as its own paint, never its members'.
let outer = rect(&mut s, 100.0, 0.0, 50.0, 50.0);
let inner = rect(&mut s, 110.0, 10.0, 20.0, 20.0);
fill(&mut s, outer, "#000000");
fill(&mut s, inner, "#000000");
sel(&mut s, &[outer, inner]);
let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t, compound]);
fill(&mut s, compound, "#ff0000");
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t]);
assert_eq!(pick(&mut s, json!({"fill": "#ff0000"})), vec![c, compound]);
// Type without characters reads as the default character fill, black.
let empty = text(&mut s, "x");
if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(empty).map(|n| &mut n.kind) {
t.runs.clear();
}
assert_eq!(pick(&mut s, json!({"fill": "#000000"})), vec![a, t, empty]);
assert!(s.execute("select.color", &json!({})).is_err());
assert!(s.execute("select.color", &json!({"fill": "not a colour"})).is_err());
}
#[test]
fn select_direction_handles_selects_all_anchors() {
let mut s = session();
+17
View File
@@ -160,3 +160,20 @@ fn newer_keys_and_legacy_profiles_survive_open_and_save() {
let v2 = json_of(&saved(&mut s, json!({"version": 2})));
assert_eq!(v2["document"]["unknown"]["colorProfiles"]["rgb"], "sRGB IEC61966-2.1");
}
/// A save records the date the document was made in, so the same commands twice gave different
/// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and
/// `SOURCE_DATE_EPOCH` for an export that takes no date param.
#[test]
fn a_pinned_date_makes_saves_byte_identical() {
let once = |t: i64| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200, "created": t})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
saved(&mut s, json!({}))
};
let at = 1_791_208_980;
assert_eq!(once(at), once(at), "the same document and date give the same bytes");
// The date really is in the bytes, so the comparison above is not vacuous.
assert_ne!(once(at), once(at + 1), "another date shows in the bytes");
}
+390
View File
@@ -0,0 +1,390 @@
//! Object › Objects on Path (#2545): objects attached to a path, spread along it and turned to
//! follow it, kept there while the path is edited. Driven through `Session::execute`.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use super::tests_clip::{id_of, select};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
id_of(&s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn center(s: &Session, id: NodeId) -> Point {
let c = bounds(s, id).center();
Point::new((c.x * 1000.0).round() / 1000.0, (c.y * 1000.0).round() / 1000.0)
}
fn attach(s: &mut Session, path: NodeId, objects: &[NodeId], extra: Value) -> NodeId {
select(s, objects);
let mut p = json!({ "path": path.0 });
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
id_of(&s.execute("object.objectsOnPath.attach", &p).unwrap())
}
fn set_line(s: &mut Session, id: NodeId, a: (f64, f64), b: (f64, f64)) {
let anchors = json!([{"x": a.0, "y": a.1}, {"x": b.0, "y": b.1}]);
s.execute("path.setAnchors", &json!({"id": id.0, "subpaths": [{"anchors": anchors, "closed": false}]})).unwrap();
}
/// Three 20 pt squares, out of order on the canvas, and a 300 pt line from (50, 300) to the right.
fn squares_and_line(s: &mut Session) -> ([NodeId; 3], NodeId) {
let right = rect(s, 250.0, 20.0, 20.0, 20.0);
let left = rect(s, 10.0, 100.0, 20.0, 20.0);
let middle = rect(s, 120.0, 0.0, 20.0, 20.0);
let path = line(s, (50.0, 300.0), (350.0, 300.0));
([left, middle, right], path)
}
#[test]
fn attach_spreads_the_objects_evenly_along_an_open_path_from_left_to_right() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[right, left, middle], json!({}));
let d = &s.doc().unwrap().doc;
let n = d.node(g).unwrap();
assert_eq!(n.kind_label(), "Objects on Path");
assert_eq!(n.children().unwrap().first().map(|c| c.id), Some(path), "the path is at the bottom of the group");
let rec = n.extras().objects_on_path.as_deref().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [left, middle, right], "left to right on the canvas");
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
// First on the start, last on the end, evenly between: the centers on the line.
assert_eq!(center(&s, left), Point::new(50.0, 300.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
assert_eq!(center(&s, right), Point::new(350.0, 300.0));
let b = bounds(&s, middle);
assert!((b.width() - 20.0).abs() < 1e-9 && (b.height() - 20.0).abs() < 1e-9, "upright on a level path: {b:?}");
}
#[test]
fn objects_in_a_column_go_top_to_bottom() {
let mut s = session();
// Its left edge is the leftmost, but it is below the other one in their column.
let lower = rect(&mut s, 8.0, 80.0, 20.0, 20.0);
let upper = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let beside = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
let path = line(&mut s, (0.0, 300.0), (300.0, 300.0));
let g = attach(&mut s, path, &[beside, lower, upper], json!({}));
let ids: Vec<NodeId> = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect();
assert_eq!(ids, [upper, lower, beside]);
}
#[test]
fn the_objects_turn_with_the_path_and_rotate_tilts_them() {
let mut s = session();
// A 40 × 10 bar on a path that runs straight down: it turns a quarter.
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (200.0, 50.0), (200.0, 350.0));
let g = attach(&mut s, path, &[bar, other], json!({}));
let b = bounds(&s, bar);
assert!((b.width() - 10.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "across the path: {b:?}");
assert_eq!(center(&s, bar), Point::new(200.0, 50.0));
// Rotate 90: turned back level, still centered on the path.
s.execute("object.objectsOnPath.options", &json!({"rotate": 90})).unwrap();
let b = bounds(&s, bar);
assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 10.0).abs() < 1e-6, "tilted by Rotate: {b:?}");
assert_eq!(center(&s, other), Point::new(200.0, 350.0));
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!((rec.pivot, rec.rotate), (4, 90.0));
// The same angle given to Attach.
let mut t = session();
let bar = rect(&mut t, 0.0, 0.0, 40.0, 10.0);
let path = line(&mut t, (200.0, 50.0), (200.0, 350.0));
attach(&mut t, path, &[bar], json!({"rotate": 90}));
let b = bounds(&t, bar);
assert!((b.width() - 40.0).abs() < 1e-6 && (b.height() - 10.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn a_turned_object_attaches_square_to_the_path_or_across_it() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let b = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
s.execute("object.rotate", &json!({"ids": [a.0], "angle": 30})).unwrap();
s.execute("object.rotate", &json!({"ids": [b.0], "angle": 60})).unwrap();
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
attach(&mut s, path, &[a, b], json!({}));
let (ba, bb) = (bounds(&s, a), bounds(&s, b));
assert!((ba.width() - 40.0).abs() < 1e-6 && (ba.height() - 10.0).abs() < 1e-6, "30° rounds to level: {ba:?}");
assert!((bb.width() - 10.0).abs() < 1e-6 && (bb.height() - 40.0).abs() < 1e-6, "60° rounds to upright: {bb:?}");
}
#[test]
fn a_reflected_group_keeps_the_mirrored_tilt() {
let mut s = session();
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[bar, other], json!({"rotate": 30}));
// The x of the bar's topmost corner, from its center: tilted counter-clockwise, it is on the right.
let top_x = |s: &Session| {
let n = s.doc().unwrap().doc.node(bar).unwrap();
let anchors = &n.path_data().unwrap().subpaths[0].anchors;
let top = anchors.iter().min_by(|a, b| a.p.y.total_cmp(&b.p.y)).unwrap().p;
top.x - bounds(s, bar).center().x
};
assert!(top_x(&s) > 1.0);
select(&mut s, &[g]);
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
assert!(top_x(&s) < -1.0, "mirrored: tilted clockwise");
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.rotate, -30.0);
}
#[test]
fn scaling_the_group_out_of_proportion_keeps_the_scaled_tilt_and_scaling_back_restores_it() {
let mut s = session();
let bar = rect(&mut s, 0.0, 0.0, 40.0, 10.0);
let other = rect(&mut s, 100.0, 0.0, 40.0, 10.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[bar, other], json!({"rotate": 45}));
let anchors = |s: &Session| -> Vec<(f64, f64)> {
let n = s.doc().unwrap().doc.node(bar).unwrap().clone();
n.path_data().unwrap().subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect()
};
let before = anchors(&s);
select(&mut s, &[g]);
s.execute("object.scale", &json!({"sx": 200, "sy": 100, "origin": [200, 300]})).unwrap();
// The bar's long edge was at 45°: twice as wide, it slopes at atan(1/2).
let a = anchors(&s);
let (dx, dy) = (a[1].0 - a[0].0, a[1].1 - a[0].1);
assert!((dy.abs().atan2(dx.abs()).to_degrees() - 0.5f64.atan().to_degrees()).abs() < 1e-6, "{a:?}");
s.execute("object.scale", &json!({"sx": 50, "sy": 100, "origin": [200, 300]})).unwrap();
for (p, q) in anchors(&s).iter().zip(&before) {
assert!((p.0 - q.0).abs() < 1e-6 && (p.1 - q.1).abs() < 1e-6, "{:?} against {before:?}", anchors(&s));
}
}
#[test]
fn pivot_picks_the_point_that_sits_on_the_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
attach(&mut s, path, &[left, middle, right], json!({"pivot": 7}));
// The middle of each square's bottom edge is on the line: the squares stand on it.
let b = bounds(&s, middle);
assert!((b.y1 - 300.0).abs() < 1e-6 && (b.center().x - 200.0).abs() < 1e-6, "{b:?}");
s.execute("object.objectsOnPath.options", &json!({"pivot": 1})).unwrap();
let b = bounds(&s, middle);
assert!((b.y0 - 300.0).abs() < 1e-6, "hanging from the line by their top edge: {b:?}");
}
#[test]
fn a_closed_path_spreads_the_objects_all_the_way_round_from_its_first_anchor() {
let mut s = session();
let ids: Vec<NodeId> = (0..4).map(|i| rect(&mut s, 10.0 + 40.0 * f64::from(i), 0.0, 10.0, 10.0)).collect();
let ring = rect(&mut s, 100.0, 100.0, 200.0, 200.0);
let first = s.doc().unwrap().doc.node(ring).unwrap().path_data().unwrap().subpaths[0].anchors[0].p;
attach(&mut s, ring, &ids, json!({}));
assert_eq!(center(&s, ids[0]), first, "the first object on the first anchor");
let mut at: Vec<(i64, i64)> = ids.iter().map(|id| center(&s, *id)).map(|p| (p.x.round() as i64, p.y.round() as i64)).collect();
at.sort();
assert_eq!(at, [(100, 100), (100, 300), (300, 100), (300, 300)], "a quarter of the way round apart");
}
#[test]
fn editing_the_path_moves_the_objects_and_undo_takes_it_back() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
attach(&mut s, path, &[left, middle, right], json!({}));
set_line(&mut s, path, (50.0, 250.0), (250.0, 250.0));
assert_eq!(center(&s, left), Point::new(50.0, 250.0));
assert_eq!(center(&s, middle), Point::new(150.0, 250.0));
assert_eq!(center(&s, right), Point::new(250.0, 250.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "one undo step puts the path and the objects back");
// Attach itself is one step.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(center(&s, middle), Point::new(130.0, 10.0));
let d = &s.doc().unwrap().doc;
assert!(d.layers.iter().all(|l| l.children().unwrap().iter().all(|c| !vectorcraft_doc::objects_on_path::is_objects_on_path(c))));
}
#[test]
fn an_object_moved_on_its_own_goes_back_on_the_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
s.execute("object.move", &json!({"ids": [middle.0], "dx": 30, "dy": -40})).unwrap();
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
// The whole group moves as one.
select(&mut s, &[g]);
s.execute("object.move", &json!({"dx": 10, "dy": 20})).unwrap();
assert_eq!(center(&s, middle), Point::new(210.0, 320.0));
assert_eq!(center(&s, right), Point::new(360.0, 320.0));
}
#[test]
fn objects_moved_into_the_group_join_the_end_and_ones_moved_out_leave() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle], json!({}));
s.execute("layer.move", &json!({"ids": [right.0], "target": g.0, "place": "inside"})).unwrap();
assert_eq!(center(&s, left), Point::new(50.0, 300.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0));
assert_eq!(center(&s, right), Point::new(350.0, 300.0), "the new object goes at the end");
s.execute("layer.move", &json!({"ids": [middle.0], "target": g.0, "place": "above"})).unwrap();
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [left, right]);
assert_eq!(center(&s, right), Point::new(350.0, 300.0), "two objects stay on the ends");
}
#[test]
fn art_that_replaces_an_object_keeps_its_place_on_the_path() {
let mut s = session();
let mark = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let first = id_of(&s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark.0]})).unwrap());
let second = id_of(&s.execute("symbol.place", &json!({"name": "Mark", "x": 100, "y": 10})).unwrap());
let third = id_of(&s.execute("symbol.place", &json!({"name": "Mark", "x": 200, "y": 10})).unwrap());
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
let g = attach(&mut s, path, &[first, second, third], json!({}));
let v = s.execute("symbol.breakLink", &json!({"ids": [first.0]})).unwrap();
let art = v["ids"].as_array().and_then(|a| a.first()).and_then(Value::as_u64).map(NodeId).unwrap();
let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap();
assert_eq!(rec.members.iter().map(|m| m.id).collect::<Vec<_>>(), [art, second, third], "the art takes the instance's place");
assert_eq!(center(&s, art), Point::new(50.0, 300.0));
assert_eq!(center(&s, third), Point::new(350.0, 300.0));
}
#[test]
fn a_shaper_group_stays_on_the_path_when_a_source_changes() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
rect(&mut s, 50.0, 0.0, 100.0, 100.0);
let pts = [[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]];
let shaper = NodeId(s.execute("shaper.scribble", &json!({"points": pts})).unwrap()["id"].as_u64().unwrap());
let other = rect(&mut s, 300.0, 0.0, 20.0, 20.0);
let path = line(&mut s, (50.0, 300.0), (350.0, 300.0));
attach(&mut s, path, &[shaper, other], json!({}));
assert_eq!(center(&s, shaper), Point::new(50.0, 300.0));
select(&mut s, &[a]);
s.execute("object.setBounds", &json!({"width": 200.0, "height": 100.0, "reference": 0})).unwrap();
// The rebuilt art is wider, and its center is still on the start of the path.
assert!(bounds(&s, shaper).width() > 180.0, "{:?}", bounds(&s, shaper));
assert_eq!(center(&s, shaper), Point::new(50.0, 300.0));
}
#[test]
fn the_group_saves_and_reopens_live_and_exports_as_plain_art() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({"rotate": 30, "pivot": 7}));
let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap();
let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.clone();
assert_eq!(o.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path, before);
set_line(&mut o, path, (50.0, 200.0), (350.0, 200.0));
assert!((bounds(&o, middle).center().x - bounds(&s, middle).center().x).abs() < 1e-6);
assert!(bounds(&o, middle).center().y < 210.0, "editing the reopened path moves the objects");
for format in ["svg", "pdf"] {
let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": format!("objects.{format}"), "dataBase64": data})).unwrap();
let r = vectorcraft_render::Renderer::new().render(&o.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
let c = bounds(&s, middle).center();
assert!(r.pixel(c.x as u32, c.y as u32 - 4)[3] > 128, "{format}: the square is drawn where it is attached");
assert!(r.pixel(130, 10)[3] < 10, "{format}: not where it was drawn");
}
}
#[test]
fn a_pasted_copy_stays_live_on_its_own_path() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
select(&mut s, &[g]);
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.objects.to_vec();
let [copy] = copy[..] else { panic!("{copy:?}") };
assert_ne!(copy, g);
let n = s.doc().unwrap().doc.node(copy).unwrap().clone();
let rec = n.extras().objects_on_path.as_deref().cloned().unwrap();
let kids: Vec<NodeId> = n.children().unwrap().iter().map(|c| c.id).collect();
assert_eq!(kids.first(), Some(&rec.path), "the copy names its own path");
assert!(rec.members.iter().all(|m| kids.contains(&m.id) && ![left, middle, right].contains(&m.id)));
let (a, b) = (s.doc().unwrap().doc.node(rec.path).unwrap().geometric_bounds().unwrap(), bounds(&s, path));
set_line(&mut s, rec.path, (a.x0, a.y0 - 50.0), (a.x1, a.y0 - 50.0));
assert!((bounds(&s, rec.members[1].id).center().y - (a.y0 - 50.0)).abs() < 1e-6, "the copy's objects follow the copy's path");
assert_eq!(bounds(&s, path), b);
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "the original stays");
}
#[test]
fn expand_leaves_a_plain_group() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
let v = s.execute("object.objectsOnPath.expand", &json!({})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
assert_eq!(n.kind_label(), "Group");
assert_eq!(n.children().unwrap().len(), 4);
set_line(&mut s, path, (50.0, 250.0), (250.0, 250.0));
assert_eq!(center(&s, middle), Point::new(200.0, 300.0), "the objects stay where they were");
assert!(s.execute("object.objectsOnPath.expand", &json!({})).is_err(), "nothing left to expand");
}
#[test]
fn attach_without_a_path_chooses_the_tool() {
let mut s = session();
let ([left, ..], _) = squares_and_line(&mut s);
select(&mut s, &[left]);
assert_eq!(s.execute("object.objectsOnPath.attach", &json!({})).unwrap()["tool"], "objectsOnPath");
assert_eq!(s.tool_id(), "objectsOnPath");
}
#[test]
fn what_cannot_be_attached_is_refused_with_a_reason() {
let mut s = session();
let ([left, _, right], path) = squares_and_line(&mut s);
let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.attach", &p).unwrap_err().to_string();
select(&mut s, &[path]);
assert!(err(&mut s, json!({"path": path.0})).contains("select the objects"), "the path alone");
let dot = line(&mut s, (10.0, 10.0), (10.0, 10.0));
assert!(err(&mut s, json!({"path": dot.0, "ids": [left.0]})).contains("pick a path"), "a path without length");
let guide = line(&mut s, (0.0, 380.0), (300.0, 380.0));
select(&mut s, &[guide]);
s.execute("view.guides.make", &json!({})).unwrap();
assert!(err(&mut s, json!({"path": guide.0, "ids": [right.0]})).contains("pick a path"), "a guide");
let g = attach(&mut s, path, &[right], json!({}));
let other = line(&mut s, (0.0, 350.0), (300.0, 350.0));
assert!(err(&mut s, json!({"path": other.0, "ids": [right.0]})).contains("already on a path"));
assert!(err(&mut s, json!({"path": path.0, "ids": [left.0]})).contains("already in an Objects on Path group"));
assert!(err(&mut s, json!({"path": other.0, "ids": [g.0], "pivot": 9})).contains("pivot"));
assert!(s.execute("object.objectsOnPath.options", &json!({"ids": [other.0]})).is_err());
}
#[test]
fn options_and_expand_take_ids_without_a_selection() {
let mut s = session();
let ([left, middle, right], path) = squares_and_line(&mut s);
let g = attach(&mut s, path, &[left, middle, right], json!({}));
s.execute("select.set", &json!({"ids": []})).unwrap();
// An object in the group names the group.
let v = s.execute("object.objectsOnPath.options", &json!({"ids": [middle.0], "pivot": 7})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
assert!((bounds(&s, middle).y1 - 300.0).abs() < 1e-6, "standing on the line");
let v = s.execute("object.objectsOnPath.expand", &json!({"ids": [g.0]})).unwrap();
assert_eq!(v["ids"], json!([g.0]));
assert_eq!(s.doc().unwrap().doc.node(g).unwrap().kind_label(), "Group");
assert!(s.execute("object.objectsOnPath.expand", &json!({"ids": [g.0]})).is_err(), "no longer an Objects on Path group");
}
+17
View File
@@ -3,6 +3,8 @@
//! report mixed values, and saves and exports never carry the editing layer.
use serde_json::{Value, json};
use vectorcraft_geom::Affine;
use vectorcraft_render::{RenderOptions, Renderer};
use super::*;
use crate::cmd::maskedit::MASK_EDIT_LAYER;
@@ -46,6 +48,21 @@ fn data(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
/// #2727: the canvas passes `isolation` while mask art is edited; the editing layer is not drawn on
/// the page, so isolation dimming must not run or the artwork washes out.
#[test]
fn mask_editing_does_not_dim_the_canvas() {
let mut s = session();
editing_mask(&mut s);
let st = s.doc().unwrap();
let layer = st.isolation.expect("mask editing isolates the temporary layer");
let render = |opts: &RenderOptions| Renderer::new().render(&st.doc, 300, 300, Affine::IDENTITY, opts);
let plain = render(&RenderOptions::default());
let canvas = render(&RenderOptions { isolated: Some(layer), ..Default::default() });
assert_eq!(plain.pixel(50, 100), canvas.pixel(50, 100), "inside the mask");
assert_eq!(plain.pixel(250, 100), canvas.pixel(250, 100), "outside the mask");
}
#[test]
fn mask_options_act_on_the_masked_object_while_editing() {
let mut s = session();
+4 -1
View File
@@ -1181,7 +1181,10 @@ fn new_type_starts_with_placeholder_text() {
gesture(&mut s, v, &[(Down, 100.0, 150.0), (Drag, 300.0, 300.0), (Up, 300.0, 300.0)]);
assert!(text(&s).len() > placeholder.len());
set_pref(&mut s, "placeholderText", json!(false));
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
// The first click ends editing the area type (#2871); the second places point type.
for _ in 0..2 {
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
}
assert_eq!(text(&s), "");
}
+38
View File
@@ -131,6 +131,44 @@ fn mismatched_and_missing_bindings_are_skipped_and_counted() {
assert_eq!(plain(&s, t), "y");
}
/// A row with no value for any bound variable, such as one from Import Data Sets… before anything
/// is bound, applies nothing, and the reply's `warning` names it. A bound variable with a value in
/// the row gives no warning, even when its object is locked and skipped.
#[test]
fn a_row_with_no_value_for_a_bound_variable_warns_that_nothing_was_applied() {
let mut s = session();
let t = text(&mut s, "x");
s.execute("dataset.import", &json!({"csv": "name,Name,Title\nA,Ann,Boss\nB,Bob,Chief\n"})).unwrap();
let warning = |row: &str| format!("nothing in data set {row} was applied: no object is bound to a variable it has a value for");
let v = s.execute("dataset.select", &json!({"name": "A"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64(), &v["warning"]), (Some(0), Some(0), &json!(warning("A"))), "{v}");
let v = s.execute("dataset.next", &json!({})).unwrap();
assert_eq!(v["warning"], json!(warning("B")), "{v}");
let v = s.execute("dataset.prev", &json!({})).unwrap();
assert_eq!(v["warning"], json!(warning("A")), "{v}");
assert_eq!(plain(&s, t), "x");
// A bound variable that the row has no value for still applies nothing.
s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Other", "ids": [t.0]})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap();
assert_eq!((v["applied"].as_u64(), v["skipped"].as_u64(), &v["warning"]), (Some(0), Some(1), &json!(warning("B"))), "{v}");
// Bound to a variable with a value, the row applies it and gives no warning.
s.execute("variable.bind", &json!({"variable": "Title", "ids": [t.0]})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "A"})).unwrap();
assert_eq!(v["applied"].as_u64(), Some(1), "{v}");
assert!(v.get("warning").is_none(), "{v}");
assert_eq!(plain(&s, t), "Boss");
// Locked art is skipped, and its binding still counts as bound.
s.execute("select.set", &json!({"ids": [t.0]})).unwrap();
s.execute("object.lock", &json!({})).unwrap();
let v = s.execute("dataset.select", &json!({"name": "B"})).unwrap();
assert_eq!(v["applied"].as_u64(), Some(0), "{v}");
assert!(v.get("warning").is_none(), "{v}");
}
#[test]
fn deleting_a_variable_cleans_bindings_values_and_leaves_datasets_alone() {
let mut s = session();
+37
View File
@@ -227,6 +227,43 @@ fn live_objects_draw_through_new_color_settings() {
}
}
/// The art drawn for 3D effects and color adjustments follows the Color Settings: in the next
/// frame, a CMYK cyan rectangle with Revolve, with Extrude & Bevel, with Inflate, with Rotate, with
/// Brightness/Contrast, and a group holding it with Brightness/Contrast, draw through the new
/// working CMYK profile.
#[test]
fn effect_art_draws_through_new_color_settings() {
let _g = settings();
let old = cms::active_settings();
let cyan = json!({"c": 100, "m": 0, "y": 0, "k": 0});
let brighter = json!({"brightness": 30});
let cases = [
("the revolved rectangle", "threeD.revolve", json!({}), false),
("the extruded rectangle", "threeD.extrude", json!({}), false),
("the inflated rectangle", "threeD.inflate", json!({}), false),
("the rotated rectangle", "threeD.rotate", json!({}), false),
("the adjusted rectangle", "adjust.brightnessContrast", brighter.clone(), false),
("the adjusted group", "adjust.brightnessContrast", brighter, true),
];
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let frame = |r: &mut Renderer, s: &Session| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &opts).pixels;
for (what, effect, params, grouped) in cases {
let mut s = session();
let shape = rect(&mut s, 150.0, 60.0, 60.0, 120.0);
exec(&mut s, "paint.setFill", json!({"ids": [shape.0], "color": cyan}));
let target = if grouped { id_of(&exec(&mut s, "object.group", json!({"ids": [shape.0]}))) } else { shape };
exec(&mut s, "effect.apply", json!({"ids": [target.0], "effect": effect, "params": params}));
let mut r = Renderer::new();
let before = frame(&mut r, &s);
s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
let fresh = frame(&mut Renderer::new(), &s);
let warm = frame(&mut r, &s);
cms::set_active(&old).unwrap();
assert!(fresh != before, "{what}: the new profile changes the frame");
assert!(warm == fresh, "{what}: drawn as a new renderer draws it");
}
}
/// #2276: with a working RGB space other than sRGB, the Eyedropper takes an image's sRGB pixel
/// into the working space, so the sampled fill shows as the pixel it was picked from.
#[test]
+2
View File
@@ -219,6 +219,8 @@ fn structured_junk() {
("path.insertAnchor", json!({"id": 2, "subpath": 0, "segment": 99, "t": 5})),
("path.insertAnchor", json!({"id": 2, "subpath": 7, "segment": 0, "t": -1})),
("path.setHandle", json!({"id": 2, "subpath": 0, "anchor": 100, "which": "in", "x": 0, "y": 0})),
("path.moveAnchor", json!({"id": 2, "subpath": 0, "anchor": 100, "x": 0, "y": 0})),
("path.moveAnchor", json!({"id": 2, "subpath": 0, "anchor": 0, "x": 1e308, "y": -1e308})),
("path.removeAnchor", json!({"id": 2, "subpath": 0, "anchor": 0})),
("path.split", json!({"id": 2, "subpath": 0, "segment": 1000, "t": 0.5})),
("path.reshapeSegment", json!({"id": 2, "subpath": 0, "segment": 0, "t": 2.0, "dx": 1e308, "dy": 0})),
+74 -1
View File
@@ -1428,6 +1428,57 @@ proptest! {
}
}
/// Targa files, which start with no signature: run-length encoded 24 bits (as the `image` crate
/// writes them), a 16-bit export with a one-bit alpha, and by hand a 16-bit run-length encoded file
/// stored right to left and top down, a run-length encoded color-mapped one with 16-bit entries,
/// and a 32-bit one with an extension area.
fn targa_samples() -> Vec<(&'static str, Vec<u8>)> {
let mut rle = vec![];
image::RgbImage::from_pixel(5, 3, image::Rgb([200, 40, 10])).write_to(&mut std::io::Cursor::new(&mut rle), image::ImageFormat::Tga).unwrap();
let mut s = rich_session();
let r = s.execute("document.export", &json!({"format": "tga", "depth": 16, "ppi": 2})).unwrap();
let export = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let mut alpha = vec![0, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 2, 0, 16, 0x31, 0x02, 0x00, 0xfc, 0x1f, 0x80, 0x00, 0x00];
alpha.extend([0x82, 0xe0, 0x83]);
let mut mapped = vec![0, 1, 9, 5, 0, 2, 0, 16, 0, 0, 0, 0, 3, 0, 2, 0, 8, 0x21, 0x00, 0x00, 0x00, 0xfc];
mapped.extend([0x80, 6, 0x00, 5, 0x83, 6]);
let mut extended = vec![0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 0x28, 50, 50, 50, 128];
let mut area = vec![0; 495];
area[..2].copy_from_slice(&495u16.to_le_bytes());
area[494] = 4;
extended.extend(area);
extended.extend(22u32.to_le_bytes());
extended.extend([0; 4]);
extended.extend(b"TRUEVISION-XFILE.\0");
vec![("a.tga", rle), ("b.tga", export), ("c.tga", alpha), ("d.vda", mapped), ("e.vst", extended)]
}
proptest! {
#![proptest_config(config())]
/// Mutated Targa files (headers, run-length packets, color maps, the extension area and the
/// footer) never crash placing them.
#[test]
fn mutated_targa_files_place_without_panics(which in 0usize..5, cut in 0usize..600, edits in prop::collection::vec((0usize..560, any::<u8>()), 0..12)) {
let (name, mut bytes) = targa_samples().swap_remove(which);
for (at, b) in edits {
if let Some(x) = bytes.get_mut(at) {
*x = b;
}
}
bytes.truncate(cut.max(8));
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "thumbnail": 8});
let _ = no_panic(s.execute("file.place.info", &p));
let _ = no_panic(s.execute("file.place", &p));
let _ = no_panic(s.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})));
});
prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err());
}
}
// ---------- SVG saved with hidden layers and editing data ----------
/// The rich document with a hidden layer, saved as SVG with its editing data.
@@ -1741,6 +1792,27 @@ proptest! {
let text = mutate_text(&ccitt_images_eps(), cut, &edits);
survive("mutated EPS CCITT images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
}
/// TIFF-predicted Flate data (Predictor 2) at every depth the filter accepts, with any bytes, 1 to 5
/// colors and 1 to 39 columns, read by images of up to 23 × 7 pixels at 1, 2, 4, 8, 12 or 16 bits:
/// read as an image, or as an error.
#[test]
fn eps_tiff_predicted_images_never_panic(
data in prop::collection::vec(any::<u8>(), 0..300),
bpc in prop::sample::select(vec![1u8, 2, 4, 8, 16]),
colors in 1u8..6,
columns in 1u16..40,
image_bpc in prop::sample::select(vec![1u8, 2, 4, 8, 12, 16]),
(width, height) in (1u8..24, 1u8..8),
) {
let hex: String = deflated(&data).iter().map(|b| format!("{b:02x}")).collect();
let text = format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n40 30 scale {width} {height} {image_bpc} [{width} 0 0 -{height} 0 {height}] \
currentfile /ASCIIHexDecode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> /FlateDecode filter image\n\
{hex}>\nshowpage\n%%EOF\n"
);
survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
}
}
/// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from
@@ -1851,7 +1923,8 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
"status", "token", "bytesavailable", "resetfile", "pdfmark", "execform", "nulldevice", "strokepath", "pathforall",
"currenthsbcolor", "currentcolorrendering", "gcheck", "rootfont", "setcachedevice2", "writestring", "(%stdout) (w) file",
"//x", "{ //dup }", "/ReusableStreamDecode", "currentfile /ASCII85Decode filter /ReusableStreamDecode filter",
"<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>",
"<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /Predictor 2 /Colors 3 /BitsPerComponent 4 /Columns 3 >> /LZWDecode",
"<< /Predictor 2 /BitsPerComponent 16 /Columns 5 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>",
// Tiling patterns, uncoloured ones and patterns drawing patterns.
"<< /PatternType 1 /PaintType 1 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop 0 0 3 3 rectfill } >> matrix makepattern",
"<< /PatternType 1 /PaintType 2 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop dup setpattern 0 0 3 3 rectfill } >>",
+21 -4
View File
@@ -1324,6 +1324,10 @@ impl<'a> Reader<'a> {
// Far-Eastern type) and final-form positions (§15.3).
"Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg"
| "Tu" | "Xu" => {}
// Hyphenation and tab-ruler attributes the reader doesn't keep either (§22): `Th`
// hyphen control, `XL` hyphenation language, `Xb`/`XB` a tab definition (and the `Xe`
// tab descriptions between them).
"Th" | "XL" | "Xb" | "XB" | "Xe" => {}
"TE" => self.te(&vals),
// An image whose samples follow in comment lines (read at `%AI5_EndRaster`).
"XI" if self.raster.is_some() => {
@@ -2012,9 +2016,15 @@ impl<'a> Reader<'a> {
/// An image of operands `vals` and samples `data`.
fn image_from(&mut self, vals: Vec<V>, data: &[u8]) -> Result<(), String> {
let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default();
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else { return Ok(()) };
let Some(m) = vals.iter().find_map(|v| if let V::Arr(a) = v { Some(Self::nums(a)) } else { None }) else {
self.warn("an image VectorCraft can't read was left out");
return Ok(());
};
let n = Self::nums(&vals);
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else { return Ok(()) };
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else {
self.warn("an image VectorCraft can't read was left out");
return Ok(());
};
let (w, h) = (*w as u64, *h as u64);
if w == 0 || h == 0 || w.saturating_mul(h) > MAX_PIXELS || ![1.0, 8.0].contains(bits) {
self.warn("an image VectorCraft can't read was left out");
@@ -2080,7 +2090,10 @@ impl<'a> Reader<'a> {
rgba.extend_from_slice(&[px[0], px[1], px[2], a]);
}
}
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { return Ok(()) };
let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else {
self.warn("an image VectorCraft can't read was left out");
return Ok(());
};
let mut png = vec![];
if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
self.warn("an image VectorCraft can't read was left out");
@@ -2096,10 +2109,14 @@ impl<'a> Reader<'a> {
// The matrix is an art-space matrix (y up) applied to the pixels with their rows running
// downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead
// gives the same place only when the image isn't rotated (b = c = 0).
let [a, b, c, d, tx, ty] = m else { return Ok(()) };
let [a, b, c, d, tx, ty] = m else {
self.warn("an image VectorCraft can't read was left out");
return Ok(());
};
let to_doc = self.space();
let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP;
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
self.warn("an image VectorCraft can't read was left out");
return Ok(());
}
let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf));
+34
View File
@@ -677,6 +677,40 @@ fn images_with_alpha_channels() {
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
}
/// Editing data `data` opens without its one image and with the warning "an image VectorCraft can't read was left out" once.
fn image_left_out_with_a_warning(data: &[u8]) {
let r = read(data).unwrap();
assert!(art(&r.doc).is_empty(), "{:#?}", art(&r.doc));
let left_out = r.warnings.iter().filter(|w| *w == "an image VectorCraft can't read was left out").count();
assert_eq!(left_out, 1, "{:?}", r.warnings);
}
#[test]
fn an_image_without_a_matrix_is_left_out_with_a_warning() {
image_left_out_with_a_warning(&image("", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
}
#[test]
fn an_image_with_too_few_operands_is_left_out_with_a_warning() {
// The operands hold 10 numbers after the matrix, one short of the binary flag.
let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), b"abc", 3)).unwrap();
let data = text.replacen("] 0 0 1 1 1 1 8 3 0 0 1 0 4 4 0 0\n", "] 0 0 1 1 1 1 8 3 0 0\n", 1);
assert_ne!(data, text);
image_left_out_with_a_warning(data.as_bytes());
}
#[test]
fn an_image_whose_matrix_isnt_six_numbers_is_left_out_with_a_warning() {
image_left_out_with_a_warning(&image("[ 1 0 0 1 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
}
#[test]
fn an_image_whose_matrix_has_no_area_is_left_out_with_a_warning() {
image_left_out_with_a_warning(&image("[ 0 0 0 0 0 0 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
// This matrix maps the image onto a line.
image_left_out_with_a_warning(&image("[ 1 2 2 4 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 3));
}
/// The raster matrix `[a b c d tx ty]` is an art-space matrix (y up) applied to the image's pixels
/// with their rows running downwards, `(u, -v)`: art x = a·u − c·v + tx, art y = b·u − d·v + ty.
/// Without rotation (b = c = 0) that is the same as flipping the matrix's result; a rotated image
+37 -7
View File
@@ -324,16 +324,46 @@ impl Predictor {
// Bytes per pixel (at least one) is how far back the left neighbour is.
let bpp = (self.colors * self.bpc).div_ceil(8).max(1);
if self.kind == 2 {
// Horizontal differencing (ISO 32000-1, §7.4.4.4): each component is stored as its
// difference from the same component of the pixel to its left, wrapping within its bit
// width. Each row starts again, and the padding bits at a row's end stay as they are.
let mut out = data.to_vec();
// Horizontal differencing, for 8-bit components (the common case).
if self.bpc == 8 {
for line in out.chunks_mut(row.max(1)) {
for i in bpp..line.len() {
let left = line.get(i - bpp).copied().unwrap_or(0);
if let Some(b) = line.get_mut(i) {
*b = b.wrapping_add(left);
for line in out.chunks_mut(row.max(1)) {
match self.bpc {
8 => {
for i in bpp..line.len() {
let left = line.get(i - bpp).copied().unwrap_or(0);
if let Some(b) = line.get_mut(i) {
*b = b.wrapping_add(left);
}
}
}
// 16-bit components are big-endian and add as 16-bit numbers.
16 => {
let byte = |line: &[u8], i: usize| line.get(i).copied().unwrap_or(0);
for i in (bpp..line.len().saturating_sub(1)).step_by(2) {
let left = u16::from_be_bytes([byte(line, i - bpp), byte(line, i - bpp + 1)]);
let sum = u16::from_be_bytes([byte(line, i), byte(line, i + 1)]).wrapping_add(left);
if let Some(w) = line.get_mut(i..i + 2) {
w.copy_from_slice(&sum.to_be_bytes());
}
}
}
// 1, 2 or 4 bits: components are packed from each byte's high bit.
bits @ (1 | 2 | 4) => {
let mask = (1u8 << bits) - 1;
let shift = |k: usize| 8 - bits - k * bits % 8;
let sample = |line: &[u8], k: usize| line.get(k * bits / 8).map_or(0, |b| (b >> shift(k)) & mask);
let samples = (line.len() * 8 / bits).min(self.colors * self.columns);
for k in self.colors..samples {
let sum = sample(line, k).wrapping_add(sample(line, k - self.colors)) & mask;
if let Some(b) = line.get_mut(k * bits / 8) {
*b = (*b & !(mask << shift(k))) | (sum << shift(k));
}
}
}
// `Predictor::of` accepts no other depth.
_ => {}
}
}
return out;
+17 -3
View File
@@ -186,13 +186,27 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
import_with(bytes, true)
}
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
/// (else what its page prints, as any EPS).
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
/// The PostScript of an EPS or PostScript file (behind its binary header, if it has one).
fn program(bytes: &[u8]) -> Result<&[u8], String> {
let (ps, _) = crate::sections(bytes).ok_or("the file's preview header points outside the file")?;
if !ps.starts_with(b"%!") {
return Err("this is not a PostScript file".into());
}
Ok(ps)
}
/// An EPS or PostScript file's page at the origin, read from its comments without running its
/// program: its bounding box (`%%HiResBoundingBox` when it has one), a US Letter page without
/// one. [`import_with`] runs the program on the same page when it reads what the page prints.
pub fn page(bytes: &[u8]) -> Result<Rect, String> {
let [llx, lly, urx, ury] = Dsc::read(program(bytes)?).bbox.unwrap_or(LETTER);
Ok(Rect::new(0.0, 0.0, urx - llx, ury - lly))
}
/// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data`
/// (else what its page prints, as any EPS).
pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> {
let ps = program(bytes)?;
let dsc = Dsc::read(ps);
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
// type), and its program has all of it, which is read as the format (as before the file
@@ -121,6 +121,23 @@ fn a_postscript_ai_whose_layers_cant_be_read_says_why() {
assert!(e.contains("`Adobe_packedarray`") && e.contains("its layers couldn't be read either") && e.contains("`frobnicate`"), "{e}");
}
/// A `.ai` in PostScript form (read through its layers) whose text carries hyphenation and
/// tab-ruler attributes: `XL` (hyphenation language), `Xb`/`XB` (a tab definition) and `Th`
/// (hyphen control). These keep no geometry, so the reader drops them rather than failing the
/// file the way it did before — the symptom seen on Illustrator 6/8 EPS files whose
/// `Adobe_level2_AI5` program can't be run.
#[test]
fn text_with_hyphenation_and_tab_attributes_opens_instead_of_failing() {
let text = "0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 9 Tf\n\
%_ 0 XL\n36 0 Xb\nXB\n0 1 2 2 99 Th\n0 Ta\n(hi) Tx\nTO\n";
// The operators sit on a visible layer (so they'd be reported as unknown, not quietly skipped
// on a hidden one), beside the square so the layer is never empty.
let src = NAMED_PROLOG.replace("S\nLB\n%AI5_EndLayer--\n%AI5_BeginLayer", &format!("S\n{text}LB\n%AI5_EndLayer--\n%AI5_BeginLayer"));
let r = import(src.as_bytes()).unwrap();
assert!(!r.preview, "read through the layers, not a fallback preview");
assert!(!r.warnings.iter().any(|w| w.contains("doesn't know") || w.contains("couldn't be read")), "{:?}", r.warnings);
}
/// An Illustrator 3 file of paths alone, as image editors export their paths (storytold/photocraft#6036):
/// no prolog and no layers, the paths unpainted (`n` closes, `N` doesn't), a compound path of two.
const PATHS_ONLY: &str = "%!PS-Adobe-2.0 EPSF-1.2\n%%Creator: an image editor\n%%BoundingBox: 0 0 200 100\n\
+54
View File
@@ -408,3 +408,57 @@ fn predicted_rows_of_an_odd_width() {
let want: Vec<[u8; 4]> = raw.iter().flat_map(|row| (0..3).map(move |x| [row[3 * x], row[3 * x + 1], row[3 * x + 2], 255])).collect();
assert_eq!(pixels(&r, 3, 5), want);
}
/// Rows of `bpc`-bit samples packed from the high bit (16-bit ones big-endian), each row padded to
/// a whole byte.
fn packed(rows: &[Vec<u32>], bpc: usize) -> Vec<u8> {
let mut out = vec![];
for row in rows {
let mut bytes = vec![0u8; (row.len() * bpc).div_ceil(8)];
for (k, v) in row.iter().enumerate() {
if bpc == 16 {
bytes[2 * k..2 * k + 2].copy_from_slice(&(*v as u16).to_be_bytes());
} else {
let bit = k * bpc;
bytes[bit / 8] |= (*v as u8) << (8 - bpc - bit % 8);
}
}
out.extend(bytes);
}
out
}
/// A TIFF-predicted Flate stream (Predictor 2, horizontal differencing: ISO 32000-1 §7.4.4.4) at
/// 1, 2, 4, 8 and 16 bits a component, gray and RGB, five pixels a row: each component is the
/// difference from the same component of the pixel to its left, within its bit width, and each
/// row starts again. The filter gives back the samples before differencing, read here as 8-bit
/// gray pixels one byte each.
#[test]
fn tiff_predicted_rows_at_every_depth() {
let columns = 5;
let mut wrong = vec![];
for bpc in [1, 2, 4, 8, 16] {
for colors in [1, 3] {
let max = (1u32 << bpc) - 1;
let rows: Vec<Vec<u32>> =
(0..3u32).map(|y| (0..(columns * colors) as u32).map(|i| (i * i * 40_503 + i * 7 + y * 12_345 + 1) & max).collect()).collect();
let differenced: Vec<Vec<u32>> = rows
.iter()
.map(|row| (0..row.len()).map(|i| if i < colors { row[i] } else { row[i].wrapping_sub(row[i - colors]) & max }).collect())
.collect();
let (want, data) = (packed(&rows, bpc), packed(&differenced, bpc));
let width = want.len() / 3;
let r = read(&format!(
"30 30 scale << /ImageType 1 /Width {width} /Height 3 /BitsPerComponent 8 /Decode [0 1] /ImageMatrix [{width} 0 0 -3 0 3] \
/DataSource currentfile /ASCII85Decode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> \
/FlateDecode filter >> image\n{}",
crate::ps::ascii85(&crate::ps::deflate(&data))
));
let got: Vec<u8> = pixels(&r, width as u32, 3).iter().map(|p| p[0]).collect();
if got != want {
wrong.push(format!("{bpc} bits, {colors} colors: {got:?}, not {want:?}"));
}
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
+86 -32
View File
@@ -29,7 +29,7 @@
//! `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB` open) to stroke it too.
//! - Images (§8.1, §8.4): `%AI5_File:`, `%AI5_BeginRaster`,
//! `[ a b c d tx ty ] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in hex on comment lines of 60
//! digits, `%AI5_EndRaster`.
//! digits, `%AI5_EndRaster`, then a line `F` that §8.4's examples do not show.
//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx
//! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each
//! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`,
@@ -50,7 +50,9 @@
//! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden
//! layer they stay where they are). Type that 8-bit text can't hold (characters outside
//! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its
//! outlines, and all type with [`LegacyAiOptions::outline_text`].
//! outlines, and all type with [`LegacyAiOptions::outline_text`]. Layer, spot color and gradient
//! names and the title are written in Windows-1252 too, and a character outside it is written as
//! `?`. Two spot colors or two gradients whose names become the same that way are numbered (`? 2`).
//!
//! The prolog is a procedure set of the writer's own ([`PROLOG`]) that gives the operators their
//! meaning in PostScript, so the file also prints and previews as a PostScript program (its
@@ -87,6 +89,8 @@ pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above
pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)";
pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none";
pub const LAYER_NAME: &str = "characters outside Windows Latin-1 in layer names are written as ? (legacy .ai files can't hold them)";
pub const TITLE_AND_COLOR_NAMES: &str =
"characters outside Windows Latin-1 in the title and in spot color and gradient names are written as ? (legacy .ai files can't hold them)";
pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)";
const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape";
const FREEFORM: &str = "freeform gradients are written as their average colour";
@@ -163,11 +167,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
.filter(|b| b.is_finite())
.reduce(|a, b| a.union(b))
.unwrap_or(Rect::new(0.0, below, width, top));
let title = w.latin_name(&o.title, TITLE_AND_COLOR_NAMES);
let mut s = String::new();
s.push_str(PS_HEADER);
s.push('\n');
let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION"));
let _ = writeln!(s, "%%Title: {}", ps::string(&o.title));
let _ = writeln!(s, "%%Title: {}", latin_string(&title));
if let Some(t) = o.created {
let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t);
let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})");
@@ -179,12 +184,12 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
s.push('\n');
s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n");
for (i, (name, _)) in w.spots.iter().enumerate() {
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::string(name));
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, latin_string(name));
}
for (i, (name, cmyk)) in w.spots.iter().enumerate() {
s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " });
ps::push_nums(&mut s, &cmyk.map(f64::from));
let _ = writeln!(s, "{}", ps::string(name));
let _ = writeln!(s, "{}", latin_string(name));
}
for (i, (name, _)) in w.fonts.iter().enumerate() {
let _ = writeln!(s, "{} {name}", if i == 0 { "%%DocumentFonts:" } else { "%%+" });
@@ -335,7 +340,7 @@ const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header com
/// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops
/// last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, `%AI5_EndGradient`.
fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
let name = ps::string(name);
let name = latin_string(name);
let kind = if g.kind == GradientKind::Radial { 1 } else { 0 };
let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {kind} {} Bd\n[", g.stops.len());
for stop in g.stops.iter().rev() {
@@ -356,7 +361,7 @@ fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) {
}
Ink::Spot { cmyk, name, tint } => {
ps::push_nums(s, &cmyk.map(f64::from));
let _ = write!(s, "{} ", ps::string(&name));
let _ = write!(s, "{} ", latin_string(w.spot_name(&name)));
ps::push_num(s, f64::from(1.0 - tint));
s.push_str(" 3 ");
}
@@ -393,6 +398,13 @@ fn invertible(a: Affine) -> bool {
det.is_finite() && det.abs() > 1e-12
}
/// Name `name` as a PostScript string of its characters' Windows-1252 codes (a character outside
/// Windows-1252 is left out: [`Writer::latin_name`] replaces them first).
fn latin_string(name: &str) -> String {
let bytes: Vec<u8> = name.chars().filter_map(latin::encode).collect();
ps::bytes(&bytes)
}
struct Writer<'a> {
doc: &'a Document,
/// The document → art space.
@@ -401,8 +413,10 @@ struct Writer<'a> {
body: String,
/// Gradient definitions, by the name objects use.
gradients: Vec<(String, Gradient)>,
/// Spot colours used: name and full CMYK equivalent.
/// Spot colors used: the name written and full CMYK equivalent.
spots: Vec<(String, [f32; 4])>,
/// The name each spot color (by its swatch's name) is written with ([`Self::spot`]).
spot_names: Vec<(String, String)>,
warnings: Vec<String>,
/// The lock state the last `A` set.
locked: bool,
@@ -430,6 +444,7 @@ impl<'a> Writer<'a> {
body: String::new(),
gradients: vec![],
spots: vec![],
spot_names: vec![],
warnings: vec![],
locked: false,
nest: 0,
@@ -454,23 +469,62 @@ impl<'a> Writer<'a> {
// ---------- layers and objects ----------
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
/// is written as `?`, with a warning.
fn layer_name(&mut self, name: &str) -> String {
/// `name` with each character outside Windows-1252 (the encoding type and names are written
/// in) replaced by `?`; adds the warning `warning` when it replaces one.
fn latin_name(&mut self, name: &str, warning: &str) -> String {
let mut lost = false;
let bytes: Vec<u8> = name
let kept = name
.chars()
.map(|c| {
latin::encode(c).unwrap_or_else(|| {
if latin::encode(c).is_some() {
c
} else {
lost = true;
b'?'
})
'?'
}
})
.collect();
if lost {
self.warn(LAYER_NAME);
self.warn(warning);
}
ps::bytes(&bytes)
kept
}
/// A layer's name as a PostScript string in Windows-1252, like the type; a character outside it
/// is written as `?`, with a warning.
fn layer_name(&mut self, name: &str) -> String {
latin_string(&self.latin_name(name, LAYER_NAME))
}
/// Note spot color `name` (its swatch's name), of full CMYK equivalent `cmyk`, as a custom
/// color of the document, and give the name it is written with. Its characters outside
/// Windows-1252 are written as `?` (with a warning), and it is numbered when another spot color
/// is already written with that name; the name is the same each time.
fn spot(&mut self, name: &str, cmyk: [f32; 4]) -> String {
let written = match self.spot_names.iter().find(|(n, _)| n == name) {
Some((_, w)) => w.clone(),
None => {
let base = self.latin_name(name, TITLE_AND_COLOR_NAMES);
let mut written = base.clone();
let mut i = 1;
while self.spot_names.iter().any(|(_, w)| *w == written) {
i += 1;
written = format!("{base} {i}");
}
self.spot_names.push((name.to_string(), written.clone()));
written
}
};
if !self.spots.iter().any(|(n, _)| *n == written) {
self.spots.push((written.clone(), cmyk));
}
written
}
/// The name spot color `name` (its swatch's name) is written with, once [`Self::spot`] has
/// noted it.
fn spot_name<'n>(&'n self, name: &'n str) -> &'n str {
self.spot_names.iter().find(|(n, _)| n == name).map_or(name, |(_, w)| w.as_str())
}
/// A top-level layer. False when it isn't a layer.
@@ -965,7 +1019,10 @@ impl<'a> Writer<'a> {
/// Set the fill (or with `stroke`, the stroke) colour to solid paint `p`.
fn color(&mut self, p: &Paint, stroke: bool) {
let ink = self.ink(p);
let ink = match self.ink(p) {
Ink::Spot { cmyk, name, tint } => Ink::Spot { name: self.spot(&name, cmyk), cmyk, tint },
ink => ink,
};
let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill };
let s = &mut self.body;
match ink {
@@ -983,13 +1040,10 @@ impl<'a> Writer<'a> {
}
Ink::Spot { cmyk, name, tint } => {
ps::push_nums(s, &cmyk.map(f64::from));
let _ = write!(s, "{} ", ps::string(&name));
let _ = write!(s, "{} ", latin_string(&name));
// The format's tint is how much lighter than the full colour (0: full).
ps::push_num(s, f64::from(1.0 - tint));
let _ = writeln!(s, " {}", op("x", "X"));
if !self.spots.iter().any(|(n, _)| *n == name) {
self.spots.push((name, cmyk));
}
}
}
}
@@ -1009,10 +1063,8 @@ impl<'a> Writer<'a> {
if let Some(sw) = &stop.swatch {
// Spot colours in stops are custom colours too.
let p = Paint::Solid { color: stop.color, swatch: Some(sw.clone()), tint: stop.tint };
if let Ink::Spot { cmyk, name, .. } = self.ink(&p)
&& !self.spots.iter().any(|(n, _)| *n == name)
{
self.spots.push((name, cmyk));
if let Ink::Spot { cmyk, name, .. } = self.ink(&p) {
self.spot(&name, cmyk);
}
}
}
@@ -1036,21 +1088,21 @@ impl<'a> Writer<'a> {
s.push_str("Bh\n");
}
s.push_str("1 ");
s.push_str(&ps::string(&name));
s.push_str(&latin_string(&name));
s.push(' ');
ps::push_nums(&mut s, &[start.x, start.y, angle, length]);
s.push_str("1 0 0 1 0 0 Bg\n");
s
}
/// The name of the definition of `g` (its swatch's name when it has one), defined the first
/// time.
/// The name of the definition of `g` (its swatch's name when it has one, with each character
/// outside Windows-1252 written as `?` and a warning), defined the first time.
fn define(&mut self, g: &Gradient, swatch: Option<&str>) -> String {
if let Some((name, _)) = self.gradients.iter().find(|(_, d)| d == g) {
return name.clone();
}
let taken = |n: &str, defs: &[(String, Gradient)]| defs.iter().any(|(m, _)| m == n);
let base = swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient").to_string();
let base = self.latin_name(swatch.filter(|s| !s.is_empty()).unwrap_or("Gradient"), TITLE_AND_COLOR_NAMES);
let mut name = base.clone();
let mut i = 1;
while taken(&name, &self.gradients) {
@@ -1227,7 +1279,9 @@ impl<'a> Writer<'a> {
/// An image (§8): `%AI5_File:`, `%AI5_BeginRaster`, its matrix (an art-space matrix applied to
/// the pixels with their rows running downwards, `(u, -v)`) as `[ a b c d tx ty ]` and its
/// operands with `XI`, its RGB samples in hex on comment lines of [`HEX_LINE`] digits,
/// `%AI5_EndRaster`.
/// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file
/// can leave out an image in a clipping group and the art after it. No path is open there, so
/// the `F` paints nothing.
fn image(&mut self, im: &ImageObject) {
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let m = self.to_art * im.xf * flip;
@@ -1281,7 +1335,7 @@ impl<'a> Writer<'a> {
ps::push_nums(s, &[a, b, c, d, e, f]);
let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI");
s.push_str(&hex);
s.push_str("%AI5_EndRaster\n");
s.push_str("%AI5_EndRaster\nF\n");
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ mod ps;
mod scene;
mod tiff;
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai};
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai, page};
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
pub use print::{PrintJob, PrintPage, print};
+137 -10
View File
@@ -316,14 +316,15 @@ fn images_below_a_square(images: bool) -> Document {
doc
}
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to `%AI5_EndRaster`.
/// Each image the file holds (§8.4): the lines from `%AI5_File:` to the line after
/// `%AI5_EndRaster`.
fn rasters(text: &str) -> Vec<Vec<&str>> {
let lines: Vec<&str> = text.lines().collect();
let mut out = vec![];
for (i, l) in lines.iter().enumerate() {
if *l == "%AI5_File:" {
let end = lines[i..].iter().position(|l| *l == "%AI5_EndRaster").map(|n| i + n).unwrap();
out.push(lines[i..=end].to_vec());
out.push(lines[i..=(end + 1).min(lines.len() - 1)].to_vec());
}
}
out
@@ -332,8 +333,9 @@ fn rasters(text: &str) -> Vec<Vec<&str>> {
/// Every image, in a clipping group, a group or neither, is written as §8.1's syntax and §8.4's
/// examples write one: `%AI5_File:`, `%AI5_BeginRaster`, the matrix with a space inside each
/// bracket and the operands on the `XI` line, the samples in hex on comment lines of 60 digits,
/// `%AI5_EndRaster`. The art after the images is written as it is without them, and the file
/// reads back with its layers, clipping group, images and square (#2692).
/// `%AI5_EndRaster`. A line `F`, which the examples do not show, follows each image. The art
/// after the images is written as it is without them, and the file reads back with its layers,
/// clipping group, images and square (#2692, #2830).
#[test]
fn images_are_written_in_the_form_the_specification_shows() {
let out = write(&images_below_a_square(true));
@@ -341,8 +343,8 @@ fn images_are_written_in_the_form_the_specification_shows() {
let images = rasters(&text);
assert_eq!(images.len(), 3, "{text}");
for lines in &images {
let [file, begin, xi, data @ .., end] = lines.as_slice() else { panic!("{lines:?}") };
assert_eq!((*file, *begin, *end), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster"));
let [file, begin, xi, data @ .., end, paint] = lines.as_slice() else { panic!("{lines:?}") };
assert_eq!((*file, *begin, *end, *paint), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster", "F"));
let matrix = xi.strip_prefix("[ ").and_then(|l| l.strip_suffix(" ] 0 0 16 16 16 16 8 3 0 0 0 0 XI")).unwrap_or_else(|| panic!("{xi}"));
assert_eq!(matrix.split(' ').filter(|v| v.parse::<f64>().is_ok()).count(), 6, "{xi}");
let digits: Vec<&str> = data.iter().map(|l| l.strip_prefix('%').unwrap_or_else(|| panic!("{l}"))).collect();
@@ -352,9 +354,10 @@ fn images_are_written_in_the_form_the_specification_shows() {
assert_eq!(digits.concat().len(), 16 * 16 * 3 * 2);
}
// The clipping group: `q`, the clipping path painted `h W n` (§5.6.2), the image, `Q`; the
// group: `u`, the image, `U`.
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\n"), "{text}");
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nU\n"), "{text}");
// group: `u`, the image, `U`; the loose image, then the layer's end.
assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\n"), "{text}");
assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nU\n"), "{text}");
assert!(text.contains("\n%AI5_EndRaster\nF\nLB\n"), "{text}");
let layer = |t: &str| -> String {
let at = t.find("(Above) Ln\n").unwrap();
t[at..].split("%AI5_EndLayer--").next().unwrap().to_string()
@@ -367,8 +370,11 @@ fn images_are_written_in_the_form_the_specification_shows() {
let below = children(&doc.layers[0]);
assert_eq!(below.len(), 3, "{below:#?}");
let NodeKind::Group { children: window, clip: true } = &below[0].kind else { panic!("{:?}", below[0].kind) };
assert_eq!(window.len(), 2, "{window:#?}");
assert!(matches!(window[0].kind, NodeKind::Path { clipping: true, .. }) && matches!(window[1].kind, NodeKind::Image(_)), "{window:#?}");
assert!(matches!(&below[1].kind, NodeKind::Group { children, clip: false } if matches!(children[0].kind, NodeKind::Image(_))));
assert!(
matches!(&below[1].kind, NodeKind::Group { children, clip: false } if children.len() == 1 && matches!(children[0].kind, NodeKind::Image(_)))
);
assert!(matches!(below[2].kind, NodeKind::Image(_)));
let above = children(&doc.layers[1]);
assert_eq!(above.len(), 1);
@@ -606,6 +612,127 @@ fn spot_colours_are_custom_colours() {
assert!((tint - 0.5).abs() < 1e-6);
}
/// A document with a spot color swatch for each of `spots`, a square filled with each at half
/// tint, and a square whose linear gradient fill (the first spot color to white) names the
/// gradient swatch `gradient` (the document holds no such swatch; the writer reads only the
/// fill's name).
fn spots_and_gradient(spots: &[&str], gradient: &str) -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
for (i, name) in spots.iter().enumerate() {
let c = i as f32 * 0.5;
doc.swatches.push(vectorcraft_color::Swatch {
name: (*name).into(),
paint: Paint::solid(Color::cmyk(c, 0.5, 1.0, 0.0)),
global: true,
spot: true,
});
let fill = Paint::Solid { color: Color::cmyk(c / 2.0, 0.25, 0.5, 0.0), swatch: Some((*name).into()), tint: 0.5 };
let x = 10.0 + 40.0 * i as f64;
let n = shape(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 30.0, 40.0)), fill, Paint::None);
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let mut first = GradientStop::new(0.0, Color::cmyk(0.0, 0.5, 1.0, 0.0));
first.swatch = Some(spots[0].into());
let g = Gradient { kind: GradientKind::Linear, stops: vec![first, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))] };
let mut paint = GradientPaint::new(g);
paint.swatch = Some(gradient.into());
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None);
doc.insert(Some(layer), usize::MAX, n).unwrap();
doc
}
/// `doc` written with the title `title`, and run as a PostScript program ([`prints_as`]).
fn write_titled(doc: &Document, title: &str) -> LegacyAiOutput {
let o = LegacyAiOptions { title: title.into(), created: Some(0), ..LegacyAiOptions::default() };
let out = export_legacy_ai(doc, &o).unwrap();
prints_as(&out.bytes, doc, &o);
out
}
/// The spot colors and gradients of the objects of `doc`'s first layer, in paint order: each
/// solid fill's swatch, and each gradient fill's swatch with its first stop's.
fn inks(doc: &Document) -> Vec<Option<String>> {
let mut out = vec![];
for n in children(&doc.layers[0]) {
match n.appearance.fill_paint() {
Paint::Solid { swatch, .. } => out.push(swatch.clone()),
Paint::Gradient(g) => {
out.push(g.swatch.clone());
out.push(g.gradient.stops.first().and_then(|s| s.swatch.clone()));
}
_ => {}
}
}
out
}
/// The title, spot color names and gradient names are written in Windows-1252, as layer names
/// are: é is 351 (octal) in the header's custom colors, each `x`, the gradient's definition, its
/// stops and `Bg`. The spot color and gradient names read back as they were.
#[test]
fn spot_gradient_and_title_names_are_written_in_windows_1252() {
let doc = spots_and_gradient(&["Caf\u{e9}"], "D\u{e9}grad\u{e9}");
let out = write_titled(&doc, "R\u{e9}sum\u{e9}");
let text = String::from_utf8(out.bytes.clone()).unwrap();
for needle in [
"%%Title: (R\\351sum\\351)\n",
"%%DocumentCustomColors: (Caf\\351)\n",
"%%CMYKCustomColor: 0 0.5 1 0 (Caf\\351)\n",
"0 0.5 1 0 (Caf\\351) 0.5 x\n",
"%AI5_BeginGradient: (D\\351grad\\351)\n(D\\351grad\\351) 0 2 Bd\n",
"0 0.5 1 0 (Caf\\351) 0 3 50 0 %_Bs\n",
"1 (D\\351grad\\351) ",
] {
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
}
assert!(!text.contains("\\303"), "{text}");
assert!(!out.warnings.iter().any(|w| w == TITLE_AND_COLOR_NAMES), "{:?}", out.warnings);
let read = crate::import(&out.bytes).unwrap().document;
let names = inks(&read);
let want = ["Caf\u{e9}", "D\u{e9}grad\u{e9}", "Caf\u{e9}"].map(|n| Some(n.to_string()));
assert_eq!(names, want);
}
/// A character Windows-1252 can't hold is written as `?`, with one warning for all of these
/// names. Names that become the same are numbered, so the header, each `x` and each `Bg` still
/// tell the colors and gradients apart.
#[test]
fn names_outside_windows_1252_are_replaced_and_kept_apart() {
// 赤 and 青 (spot colors), 渐变 and グラ (gradients), 日本 (the title).
let mut doc = spots_and_gradient(&["\u{8d64}", "\u{9752}"], "\u{6e10}\u{53d8}");
let g = Gradient {
kind: GradientKind::Linear,
stops: vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))],
};
let mut paint = GradientPaint::new(g);
paint.swatch = Some("\u{30b0}\u{30e9}".into());
let n = shape(&mut doc, shapes::rectangle(Rect::new(60.0, 50.0, 100.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, n).unwrap();
let out = write_titled(&doc, "\u{65e5}\u{672c}");
let text = String::from_utf8(out.bytes.clone()).unwrap();
for needle in [
"%%Title: (??)\n",
"%%DocumentCustomColors: (?)\n%%+ (? 2)\n",
"%%CMYKCustomColor: 0 0.5 1 0 (?)\n%%+ 0.5 0.5 1 0 (? 2)\n",
"0 0.5 1 0 (?) 0.5 x\n",
"0.5 0.5 1 0 (? 2) 0.5 x\n",
"(??) 0 2 Bd\n",
"(?? 2) 0 2 Bd\n",
"0 0.5 1 0 (?) 0 3 50 0 %_Bs\n",
"1 (??) ",
"1 (?? 2) ",
] {
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
}
assert_eq!(out.warnings.iter().filter(|w| *w == TITLE_AND_COLOR_NAMES).count(), 1, "{:?}", out.warnings);
assert!(!out.warnings.iter().any(|w| w == LAYER_NAME), "{:?}", out.warnings);
let read = crate::import(&out.bytes).unwrap().document;
let want = [Some("?"), Some("? 2"), Some("??"), Some("?"), Some("?? 2"), None].map(|n| n.map(String::from));
assert_eq!(inks(&read), want);
}
#[test]
fn losses_are_warned() {
let mut doc = sample();
+22
View File
@@ -28,9 +28,11 @@ pub fn polygon_covers_rect(path: &BezPath, r: Rect, tol: f64) -> bool {
// Convex: every turn goes the same way (straight runs allowed).
let n = pts.len();
let mut sign = 0.0f64;
let mut turning = 0.0f64;
for i in 0..n {
let (a, b, c) = (pts[i], pts[(i + 1) % n], pts[(i + 2) % n]);
let cross = (b - a).cross(c - b);
turning += cross.atan2((b - a).dot(c - b));
if cross.abs() <= 1e-9 {
continue;
}
@@ -40,6 +42,10 @@ pub fn polygon_covers_rect(path: &BezPath, r: Rect, tol: f64) -> bool {
return false;
}
}
// A simple convex polygon turns through exactly one full revolution; a star winds more.
if (turning.abs() - std::f64::consts::TAU).abs() > 1e-6 {
return false;
}
let inner = r.inflate(-tol, -tol);
sign != 0.0
&& [Point::new(inner.x0, inner.y0), Point::new(inner.x1, inner.y0), Point::new(inner.x1, inner.y1), Point::new(inner.x0, inner.y1)]
@@ -244,4 +250,20 @@ mod regress_tests {
let worst = (0..=200).map(|i| distance_to_outline(&bp, c.eval(i as f64 / 200.0))).fold(0.0, f64::max);
assert!(worst < 1e-6, "{worst}");
}
#[test]
fn polygon_covers_rect_rejects_self_intersecting_star() {
let mut b = BezPath::new();
for k in 0..5 {
let a = (-90.0 + 144.0 * k as f64).to_radians();
let p = Point::new(100.0 * a.cos(), 100.0 * a.sin());
if k == 0 {
b.move_to(p);
} else {
b.line_to(p);
}
}
b.close_path();
assert!(!polygon_covers_rect(&b, Rect::new(-55.0, -20.0, 55.0, 78.0), 0.01));
}
}
+13
View File
@@ -221,6 +221,19 @@ impl SubPath {
let b = &self.anchors[(i + 1) % n];
!a.has_out() && !b.has_in()
}
/// Arc-length at each anchor from the start of this subpath (one entry per anchor).
pub fn anchor_arc_positions(&self) -> Vec<f64> {
let n = self.anchors.len();
if n == 0 {
return vec![];
}
let mut pos = vec![0.0];
for i in 0..self.segment_count() {
let len = kurbo::ParamCurveArclen::arclen(&self.segment(i), 1e-3);
pos.push(pos.last().copied().unwrap_or(0.0) + len);
}
pos
}
pub fn to_bezpath_into(&self, out: &mut BezPath) {
let Some(first) = self.anchors.first() else { return };
out.move_to(first.p);
+12
View File
@@ -92,6 +92,7 @@ fn export_tool_lists_engine_formats_and_options() {
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
assert!(open["description"].as_str().unwrap().contains(".ait"));
assert!(open["description"].as_str().unwrap().contains(".eps, .epsf, .ps, "), "{open}");
assert!(open["description"].as_str().unwrap().contains(".bmp, .tga, .vda, .icb, .vst, "), "{open}");
}
/// A fake app that records `engine.execute` calls and answers each with an empty object.
@@ -132,6 +133,17 @@ fn export_is_the_same_call_in_the_app_and_headless() {
assert_eq!(direct["dataBase64"], r["dataBase64"]);
}
#[test]
fn the_raster_scale_leaves_other_formats_alone() {
// DXF's own `scale` (drawing units per mm) comes through `options`; the raster one would resize
// the drawing and leave its units unset.
let mut h = two_boards();
let dxf = |h: &mut Headless, args: Value| text(&call_tool(h, "export", &args))["dataBase64"].clone();
let plain = dxf(&mut h, json!({"format": "dxf"}));
assert!(dxf(&mut h, json!({"format": "dxf", "scale": 2})) == plain, "the raster scale changed the DXF");
assert!(dxf(&mut h, json!({"format": "dxf", "options": {"scale": 2}})) != plain, "the DXF scale was dropped");
}
#[test]
fn exporting_native_keeps_the_document_path() {
let mut h = two_boards();
+16 -4
View File
@@ -2,7 +2,7 @@
//! [`Backend`].
use serde_json::{Map, Value, json};
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format};
use vectorcraft_engine::cmd::fileio::{ARTBOARD_PARAMS, FORMATS, OPEN_EXTS, SAVE_FORMATS, format, writable_format};
use crate::backend::Backend;
@@ -1003,9 +1003,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
params.remove(k);
}
}
for k in ["path", "format", "scale", "artboard", "range", "outlineText"] {
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
params.insert(k.into(), v.clone());
for k in named_export_params(a) {
if let Some(v) = a.get(*k).filter(|v| !v.is_null()) {
params.insert((*k).into(), v.clone());
}
}
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
@@ -1054,6 +1054,18 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
}
}
/// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per
/// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`)
/// would resize the drawing and leave its units unset.
fn named_export_params(a: &Args) -> &'static [&'static str] {
let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str));
if format.is_ok_and(|f| f.raster) {
&["path", "format", "scale", "artboard", "range", "outlineText"]
} else {
&["path", "format", "artboard", "range", "outlineText"]
}
}
/// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an
/// `isError` result so the model can read and correct them.
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
+8 -5
View File
@@ -13,6 +13,7 @@ use krilla::metadata::DateTime;
use crate::PdfError;
use crate::import_color::items;
use crate::import_scan::text_string;
use crate::lab_spot::find;
/// The embedded file holding the native document (its name among the PDF's attachments).
@@ -84,8 +85,9 @@ pub(crate) fn editing_in(pdf: &Pdf) -> Option<Editing> {
let files = spec.get::<Dict<'_>>(b"EF")?;
let stream = files.get::<Stream<'_>>(b"F").or_else(|| files.get::<Stream<'_>>(b"UF"))?;
let data = stream.decoded().map(|d| d.into_owned()).unwrap_or_default();
// The hash ends the description; editing data described otherwise is trusted.
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| String::from_utf8_lossy(d.as_bytes()).into_owned()).unwrap_or_default();
// The hash ends the description (a text string, in any of its encodings); editing data
// described otherwise is trusted.
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| text_string(d.as_bytes())).unwrap_or_default();
let intact = match desc.rsplit_once("hash ") {
Some((_, hash)) => hash.trim() == page_hash(pdf),
None => true,
@@ -119,11 +121,12 @@ fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet<ObjectIden
})
}
/// A file specification naming the editing file (current or legacy name).
/// A file specification naming the editing file (current or legacy name), in any text string
/// encoding.
fn is_editing_file(spec: &Dict<'_>) -> bool {
[&b"UF"[..], b"F"].into_iter().filter_map(|k| spec.get::<PdfString<'_>>(k)).any(|name| {
let name = name.as_bytes();
name == EDITING_FILE.as_bytes() || name == LEGACY_EDITING_FILE.as_bytes()
let name = text_string(name.as_bytes());
name == EDITING_FILE || name == LEGACY_EDITING_FILE
})
}
+1 -1
View File
@@ -70,7 +70,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
// The time of writing (none on the web) dates the editing data and the page information.
let now = vectorcraft_doc::metadata::now_unix();
let now = vectorcraft_doc::metadata::output_time();
let created_at = opts.created.or(now);
crate::pdfx::require_date(set.standard, created_at)?;
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
+8 -1
View File
@@ -149,6 +149,10 @@ pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it,
/// Warning: glyphs were kept as outlines rather than read as type.
pub const OUTLINED_TEXT_NOTE: &str = "text was converted to outlines";
/// Warning: text drawn invisibly (text rendering mode 3, as a scanned page's OCR layer is) was left
/// out: VectorCraft has no type that is kept but not drawn, so a PDF saved from it loses that
/// searchable text (#2675).
pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's searchable OCR layer) was left out";
/// Warning: glyphs that differ from the installed font of their name were kept as outlines.
pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines";
/// The end of the warning that glyphs whose characters the file doesn't name were kept as
@@ -1963,7 +1967,10 @@ impl<'a> Device<'a> for Builder<'_> {
) {
self.pending = false;
let stroke = match draw_mode {
GlyphDrawMode::Invisible => return,
GlyphDrawMode::Invisible => {
self.warn(INVISIBLE_TEXT_NOTE);
return;
}
GlyphDrawMode::Fill => None,
GlyphDrawMode::Stroke(p) => Some(p.clone()),
};
+2 -2
View File
@@ -74,8 +74,8 @@ pub use encrypt::Encryption;
pub use export::{export, export_with_report, page_areas};
pub use file_info::{FileInfo, file_info};
pub use import::{
OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, import,
import_with_report, is_glyph_outlines, is_outlines_note,
INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines,
import, import_with_report, is_glyph_outlines, is_outlines_note,
};
pub use pages::{PageInfo, PdfInfo, has_illustrator_data, illustrator_data, info, is_postscript};
pub use post::{THUMBNAIL_SIZE, Thumbnail};
+1 -1
View File
@@ -355,7 +355,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>,
let set = &opts.settings;
let job = Job::new(doc, set, opts.postscript)?;
let title = opts.title.clone().unwrap_or_else(|| job.doc.title.clone());
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
let created = opts.created.or_else(vectorcraft_doc::metadata::output_time);
let sep = Separator::new(&job.doc, set);
let plates: Vec<Document> = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect();
let marks = set.marks.printer_marks();
+48 -2
View File
@@ -53,9 +53,9 @@ fn the_native_document_comes_back_intact() {
fn the_native_documents_modification_date_is_the_time_of_writing() {
// Created in 1970: the information dictionary's dates say so, the embedded file's doesn't.
let year = |t: Option<i64>| vectorcraft_doc::metadata::civil(t.unwrap())[0];
let before = year(vectorcraft_doc::metadata::now_unix());
let before = year(vectorcraft_doc::metadata::output_time());
let text = String::from_utf8_lossy(&pdf_with(Some(b"native"), false).bytes).into_owned();
let after = year(vectorcraft_doc::metadata::now_unix());
let after = year(vectorcraft_doc::metadata::output_time());
assert!(text.contains("/CreationDate(D:1970"), "the file's creation date");
assert!([before, after].iter().any(|y| text.contains(&format!("/ModDate(D:{y:04}"))), "the embedded file is dated when it is written");
}
@@ -72,6 +72,35 @@ fn changed_pages_are_noticed() {
assert!(editing(&r.bytes).unwrap().intact);
}
/// `pdf` with its editing file's description rewritten by another app as "hash " and the hash it
/// held, in the text string encoding `bom` starts (UTF-16BE or UTF-8), padded with spaces to its
/// length.
fn described(pdf: &[u8], bom: &[u8]) -> Vec<u8> {
let from = b"(VectorCraft editing data; page hash ";
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no description"));
let (hash, len) = (&pdf[at + from.len()..at + from.len() + 16], from.len() + 17);
let mut desc = [b"(", bom].concat();
for &c in b"hash ".iter().chain(hash) {
if bom.len() == 2 {
desc.push(0);
}
desc.push(c);
}
desc.push(b')');
desc.resize(len, b' ');
patched(pdf, &pdf[at..at + len], &desc)
}
#[test]
fn the_hash_is_read_from_a_description_in_any_encoding() {
let r = pdf_with(Some(b"native"), false);
let resized = patched(&r.bytes, b"/MediaBox[0 0 100 100]", b"/MediaBox[0 0 100 101]");
for bom in [&[0xFE, 0xFF][..], &[0xEF, 0xBB, 0xBF]] {
assert!(editing(&described(&r.bytes, bom)).unwrap().intact, "{bom:?}: the same pages");
assert!(!editing(&described(&resized, bom)).unwrap().intact, "{bom:?}: another page size");
}
}
#[test]
fn the_legacy_name_is_read_and_plain_pdfs_have_no_editing_data() {
let r = pdf_with(Some(b"legacy"), false);
@@ -153,3 +182,20 @@ fn direct_name_tree_kids_are_all_searched() {
let e = editing(&raw_pdf(&objects)).unwrap();
assert_eq!(e.data, b"hello");
}
#[test]
fn a_file_name_written_as_utf16_is_found() {
let utf16: String = EDITING_FILE.bytes().map(|c| format!("00{c:02X}")).collect();
for key in ["UF", "F"] {
let objects = [
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 3 0 R >> >>".to_string(),
"<< /Type /Pages /Kids [6 0 R] /Count 1 >>".to_string(),
"<< /Names [(a) 4 0 R] >>".to_string(),
format!("<< /Type /Filespec /{key} <FEFF{utf16}> /EF << /F 5 0 R >> >>"),
"<< /Length 5 >>\nstream\nhello\nendstream".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>".to_string(),
];
let e = editing(&raw_pdf(&objects)).unwrap_or_else(|| panic!("/{key}: not found"));
assert_eq!(e.data, b"hello", "/{key}");
}
}
+1 -1
View File
@@ -229,7 +229,7 @@ fn page_information_prints_the_time_of_the_export_not_the_creation_date() {
});
out
};
let now = || vectorcraft_doc::metadata::now_unix().unwrap();
let now = || vectorcraft_doc::metadata::output_time().unwrap();
// Both print the minute they are written in: try again when the minute turns between them.
for _ in 0..3 {
let minute = now() / 60;
+33
View File
@@ -177,3 +177,36 @@ pub(crate) fn transfer_contract() {
fn soft_mask_inversion_uses_quantized_transfer_endpoints() {
transfer_contract();
}
/// #2675: text in rendering mode 3 (invisible, as a scanned page's OCR layer) isn't kept, and the
/// import now says so, for type and outlines alike. Visible text beside it still comes in, and a
/// page without invisible text gets no such warning.
#[test]
fn invisible_text_is_reported_when_left_out() {
let open = |content: &str, text_as: TextAs| import_with_report(&text_pdf(content), &ImportOptions { text_as, ..Default::default() }).unwrap();
let invisible = "BT /F1 20 Tf 3 Tr 1 0 0 1 10 40 Tm (AB) Tj 0 Tr 1 0 0 1 10 10 Tm (CA) Tj ET";
for text_as in [TextAs::Text, TextAs::Outlines] {
let r = open(invisible, text_as);
assert!(r.warnings.iter().any(|w| w == crate::INVISIBLE_TEXT_NOTE), "{text_as:?}: {:?}", r.warnings);
// What came in: the type's text, and where the art lies. On the 60 pt page the visible
// line (baseline 10 pt up) lies 35–50 pt down, the invisible one (40 pt up) 5–20 pt down.
let (mut text, mut tops) = (String::new(), vec![]);
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
text.push_str(&t.plain_text());
}
if matches!(n.kind, NodeKind::Text(_) | NodeKind::Path { .. } | NodeKind::Compound { .. })
&& let Some(b) = n.visual_bounds()
{
tops.push(b.y0);
}
});
if text_as == TextAs::Text {
assert!(text.contains("CA") && !text.contains("AB"), "{text_as:?}: {text:?}");
}
assert!(!tops.is_empty(), "{text_as:?}: the visible line still comes in");
assert!(tops.iter().all(|y| *y > 25.0), "{text_as:?}: only the visible line's art, tops at {tops:?}");
let visible = open("BT /F1 20 Tf 1 0 0 1 10 10 Tm (CA) Tj ET", text_as);
assert!(!visible.warnings.iter().any(|w| w == crate::INVISIBLE_TEXT_NOTE), "{text_as:?}: {:?}", visible.warnings);
}
}
+214 -36
View File
@@ -108,6 +108,23 @@ fn evaluates_live(n: &Node) -> bool {
is_live(n) || (is_container(n) && to_members && !effects::has_pathfinder(n) && subtree_has_live(n))
}
/// Can the art [`Renderer::fx_art`] makes for `n` change while `n` stays the same, other than
/// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed),
/// a symbol instance (made from its symbol's art), or an object with a 3D effect or a color
/// adjustment (shaded or recolored through the color settings), in it or in the opacity masks of
/// the objects in it.
fn reads_outside(n: &Node) -> bool {
let mut reads = false;
n.walk(&mut |c| {
reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. })
|| effects::has_revolve(c)
|| effects::has_solid(c)
|| effects::has_adjustment(c)
|| c.extras().mask.as_ref().is_some_and(|m| reads_outside(&m.art))
});
reads
}
/// What [`Renderer::raster_fx`] paints effects around.
struct Content<'a> {
/// The object the effects belong to (cached shadows are checked against it).
@@ -584,7 +601,7 @@ thread_local! {
/// Document Raster Effects Settings › Resolution of `doc`, in ppi (1 to 2400; 72 when it isn't a
/// number).
fn doc_raster_ppi(doc: &Document) -> f64 {
Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0)
doc.raster_ppi()
}
/// The effects [`Renderer::pixel_content`] runs on the content's pixels (those changing the
@@ -620,6 +637,11 @@ pub(crate) struct PixelEntry {
rel: (f64, f64),
k: f64,
image: std::sync::Arc<vello_cpu::Pixmap>,
/// For a raster of the content's whole reach at full size, `None`: it draws after a pan too.
/// For one cropped to the view, or made at a lower resolution, the view and the rect it was
/// made for: it draws only there, so dragging other art over the object keeps it (a wide blur,
/// or any blur zoomed in, reaches past the pixel budget).
view: Option<ViewKey>,
made: Made,
/// What the content read when the raster was made, with the channels the effects ran on and,
/// under De-Interlace, the raster resolution.
@@ -648,6 +670,15 @@ fn linear_key(view: Affine) -> [u64; 4] {
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
}
/// A view's six coefficients (its pan included) and the device-pixel rect a frame draws in, as a
/// cache key: a raster cropped to that rect, or made at a lower resolution than the view's, draws
/// only in that view of that rect ([`PixelEntry::view`], [`ShadowEntry::view`]).
type ViewKey = ([u64; 6], [u64; 4]);
fn view_key(view: Affine, screen: Rect) -> ViewKey {
(view.as_coeffs().map(f64::to_bits), [screen.x0, screen.y0, screen.x1, screen.y1].map(f64::to_bits))
}
/// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while
/// [`crate::RenderOptions::dragging`] (a 1000 × 1000 raster's), when the frame does not draw a raster
/// kept from an earlier frame instead (#2558).
@@ -732,6 +763,10 @@ pub(crate) struct ShadowEntry {
image: std::sync::Arc<vello_cpu::Pixmap>,
/// View pixels per raster pixel: more than 1 for a glow made coarser to fit the budget.
scale: f64,
/// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one
/// cropped to the view, or made over such a raster, the view and the rect it was made for: it
/// draws only there ([`PixelEntry::view`]).
view: Option<ViewKey>,
made: Made,
/// What the silhouette read when the raster was made.
inputs: Inputs,
@@ -749,6 +784,8 @@ struct Shadow {
mode: vectorcraft_doc::color::BlendMode,
/// Made before its object moved ([`ShadowEntry::moved`]).
moved: bool,
/// Cropped to the view, or made over a raster that is ([`ShadowEntry::view`]).
cropped: bool,
}
impl ShadowEntry {
@@ -1012,8 +1049,13 @@ impl Renderer {
/// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU,
/// and reused while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are
/// unchanged (pans only move it), and while interacting after the object only moved
/// ([`Self::move_shadow`]). `None` when the effect isn't a shadow/glow or the raster would be
/// too large (caller falls back to [`Self::draw_shadow_filter`]).
/// ([`Self::move_shadow`]). A raster of the whole reach too large for the budget is cropped to
/// what can show in the `screen` (device pixels): the view, and around it the blur's spread and
/// `around` (document units: how far the shadows and glows stacked over this one look), made
/// coarser to fit the budget where the blur hides the coarser grid. Such a raster, and one
/// made over it (`over_cropped`), is reused only in the same view. `None` when the effect isn't
/// a shadow/glow or the raster would still be too large (caller falls back to
/// [`Self::draw_shadow_filter`]).
/// While dragging on a multithreaded context, a raster that has to be made again counts as too
/// large when it would hold more than [`DRAG_PIXEL_BUDGET`] pixels.
#[allow(clippy::too_many_arguments)]
@@ -1025,6 +1067,7 @@ impl Renderer {
fx: &RasterFx,
reach: Rect,
owner: Option<&Arc<Node>>,
(screen, around, over_cropped): (Rect, f64, bool),
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) -> Option<Shadow> {
let (mode, opacity, dx, dy, blur, color, darkness) = match fx {
@@ -1039,35 +1082,54 @@ impl Renderer {
// What the silhouette reads: as the raster kept for `n` found it, else found now.
let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads);
let inputs = self.raster_inputs(f, n, reads);
let exact = |e: &ShadowEntry| e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved);
let in_view = view_key(f.view, screen);
let exact = |e: &ShadowEntry| {
e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v == in_view)
};
if interacting && !self.shadows.get(&key).is_some_and(exact) {
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint);
}
let hit = self.shadows.get_mut(&key).filter(|e| exact(e));
let (image, x, y, scale, moved) = match hit {
let (image, x, y, scale, moved, cropped) = match hit {
Some(e) => {
e.stamp = stamp;
self.stats.moved += usize::from(e.moved);
let p = f.view * e.anchor;
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved)
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.is_some())
}
None => {
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
// An Outer Glow's silhouette grows by a quarter of a pixel of the document's raster grid first.
let grow = if matches!(fx, RasterFx::OuterGlow { .. }) { 18.0 / doc_raster_ppi(f.doc) } else { 0.0 };
let spread = (blur.max(0.0) * 1.5 + grow) / f.px + 2.0;
let reach = reach + Vec2::new(dx, dy);
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
let whole = f.view.transform_rect_bbox(reach).inflate(spread, spread);
// The raster's region and scale within `budget` pixels: `None` when over the limits.
let region = |r: Rect, coarser: bool, budget: f64| {
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
let scale = if coarser { (w * h / budget).sqrt().ceil().max(1.0) } else { 1.0 };
if !scale.is_finite() || scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 {
return None;
}
let (w, h) = ((w / scale).ceil(), (h / scale).ceil());
((1.0..=8192.0).contains(&w) && (1.0..=8192.0).contains(&h) && w * h <= budget).then_some((x0, y0, w, h, scale))
};
// A glow too large for the budget is made coarser, where its blur hides the coarser grid: the filter
// layer the caller falls back to can't make its silhouette solid.
let scale = if matches!(fx, RasterFx::OuterGlow { .. }) { (w * h / 4.0e6).sqrt().ceil().max(1.0) } else { 1.0 };
if scale > 1.0 && blur / 2.0 / f.px / scale < 2.0 {
return None;
}
let (w, h) = ((w / scale).ceil(), (h / scale).ceil());
if !(1.0..=8192.0).contains(&w) || !(1.0..=8192.0).contains(&h) || w * h > 4.0e6 {
return None;
}
let full = region(whole, matches!(fx, RasterFx::OuterGlow { .. }), 4.0e6).filter(|_| !over_cropped);
// Else it is cropped to what can show in the view, within the budget of the filter
// layer it would otherwise be drawn through, and made coarser in the same way.
let ((x0, y0, w, h, scale), cropped) = match full {
Some(r) => (r, false),
None => {
let m = spread + around / f.px;
let r = whole.intersect(screen.inflate(m, m));
if r.width() <= 0.0 || r.height() <= 0.0 {
return None;
}
(region(r, true, PIXEL_BUDGET)?, true)
}
};
// During a drag, a larger shadow on a multithreaded context is filtered within the
// drag's budget (`with_filters`), and no raster is kept.
if f.opts.dragging && f.mt && w * h > DRAG_PIXEL_BUDGET {
@@ -1112,6 +1174,7 @@ impl Renderer {
// An Outer Glow blurs a solid silhouette: wherever the art has any alpha, as AI files draw a glow round a glow.
let solid = matches!(fx, RasterFx::OuterGlow { .. });
let mut a: Vec<f32> = pm.data().iter().map(|p| if solid && p.a > 0 { 255.0 } else { p.a as f32 }).collect();
effects::pixel::dilate_plane(&mut a, wu, hu, grow / f.px / scale);
effects::pixel::blur_plane(&mut a, wu, hu, sigma);
let [cr, cg, cb] = f.ink.rgb(&color);
let tint = |v: f32, ch: f32| (v * ch).round().clamp(0.0, 255.0) as u8;
@@ -1125,12 +1188,13 @@ impl Renderer {
let p = f.view * anchor;
let node = Drawn::of(n, owner);
let rel = (x0 - p.x, y0 - p.y);
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, made, inputs, moved, stamp });
let view = cropped.then_some(in_view);
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, stamp });
self.trim_fx_rasters(FX_RASTER_BUDGET);
(image, x0, y0, scale, moved)
(image, x0, y0, scale, moved, cropped)
}
};
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved })
Some(Shadow { image, at: Affine::translate((x, y)) * Affine::scale(scale), mode, moved, cropped })
}
/// Draw shadow raster `s` through `to`, from the view the raster was made for to the one `ctx`
@@ -1221,6 +1285,7 @@ impl Renderer {
(k.1, k.2) == (key.1, key.2)
&& e.stamp < stamp
&& e.linear == linear
&& e.view.is_none()
&& e.node.id() == n.id
&& e.inputs == *inputs
&& e.made.fits(&[fx], size, None, None)
@@ -1355,8 +1420,9 @@ impl Renderer {
/// as their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their
/// knockout setting), recoloured by the colour adjustments in it ([`Self::adjusted_art`]);
/// without the object's transparency and effects (a path keeps its geometry effects). Art that
/// is made from live objects it evaluated ([`evaluates_live`]) is kept only while the inputs of
/// their evaluation stay the same ([`Self::live_found`]).
/// is made from live objects it evaluated ([`evaluates_live`]), or from type, symbol art or
/// colors shaded through the color settings ([`reads_outside`]), is kept only while what it
/// reads from outside `a` stays the same ([`Self::live_found`]).
fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>, cache: bool) -> Arc<Node> {
let key = Arc::as_ptr(a) as usize;
let mut kept = None;
@@ -1369,7 +1435,7 @@ impl Renderer {
Some(was) => kept = Some((art.clone(), was.0)),
}
}
let found = (kept.is_some() || (cache && evaluates_live(a))).then(|| self.live_found(doc, a));
let found = (kept.is_some() || (cache && (evaluates_live(a) || reads_outside(a)))).then(|| self.live_found(doc, a));
if let Some((art, was)) = kept
&& found.as_ref().is_some_and(|f| f.0 == was)
{
@@ -1562,7 +1628,17 @@ impl Renderer {
// through a filter layer from the content alone, and goes into the later silhouettes that way.
let mut made: Vec<(&RasterFx, Option<Shadow>)> = vec![];
let mut stacked = reach;
let screen = Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height()));
// How far (document units) a shadow or glow looks from a pixel to the silhouette under it.
let looks = |fx: &RasterFx| match fx {
RasterFx::DropShadow { dx, dy, blur, .. } => blur.max(0.0) * 1.5 + dx.abs() + dy.abs(),
RasterFx::OuterGlow { blur, .. } => blur.max(0.0) * 1.5,
_ => 0.0,
};
for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) {
// A raster cropped to the view covers what the shadows and glows stacked over it read.
let around: f64 = rfx.iter().skip(i + 1).filter(|x| x.is_below()).map(looks).sum();
let over_cropped = made.iter().any(|(_, s)| s.as_ref().is_some_and(|s| s.cropped));
let shadow = {
let earlier = &made;
let view = f.view;
@@ -1579,7 +1655,8 @@ impl Renderer {
}
paint(r, c, fr);
};
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), &mut art))
let view = (screen, around, over_cropped);
content.cache.and_then(|slot| self.shadow_raster(f, content.node, slot + i, fx, stacked, owner.as_ref(), view, &mut art))
};
stacked = match fx {
RasterFx::DropShadow { dx, dy, blur, .. } => stacked.union((stacked + Vec2::new(*dx, *dy)).inflate(blur * 1.5, blur * 1.5)),
@@ -1649,7 +1726,10 @@ impl Renderer {
/// every effect only looks nearby, and content still over either limit is rendered at a lower
/// resolution. A whole raster at full resolution is cached while the object, the
/// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and
/// while interacting after the content only moved ([`Self::move_pixel_content`]).
/// while interacting after the content only moved ([`Self::move_pixel_content`]). A cropped or
/// lower-resolution raster is cached while the object, the view (its pan included) and what the
/// content reads stay the same ([`PixelEntry::view`]); a lower-resolution one is kept resampled to
/// the view's pixels ([`Self::resampled`]).
/// While dragging, content without such a raster to draw renders within [`DRAG_PIXEL_BUDGET`]
/// pixels and is not cached.
fn pixel_content(
@@ -1699,9 +1779,11 @@ impl Renderer {
let side = f64::from(crate::MAX_RASTER_SIDE);
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
let (w, h) = ((rw * k).ceil().clamp(1.0, side), (rh * k).ceil().clamp(1.0, side));
// The cached raster, when the whole content was rendered at full size at this zoom.
// The cached raster: of the whole content at full size at this zoom, else of this view.
let linear = linear_key(f.view);
let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink));
let whole = fits && k >= 1.0;
let in_view = (!whole).then(|| view_key(f.view, screen));
let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink));
let stamp = self.stamp;
let channels = match f.ink {
Ink::Cmy => effects::pixel::Channels::CmyPlane,
@@ -1719,8 +1801,11 @@ impl Renderer {
self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits())))
});
let interacting = f.opts.interacting;
let exact = |e: &PixelEntry| e.linear == linear && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved);
let exact = |e: &PixelEntry| {
e.linear == linear && e.view == in_view && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved)
};
if interacting
&& whole
&& let Some(key) = key
&& let Some(inputs) = &inputs
&& !self.pixel_fx.get(&key).is_some_and(exact)
@@ -1794,9 +1879,10 @@ impl Renderer {
let failed = crate::paint::decode_failures() - decoded;
// Content drawn over moved rasters makes a moved raster.
let moved = self.stats.moved > before;
// What the raster is made from, for its object once it moves (`move_pixel_content`).
// What the raster is made from, for its object once it moves (`move_pixel_content`): a
// cropped or coarser raster never moves.
let made = key.map(|_| Made {
pixels: pm.data_as_u8_slice().to_vec(),
pixels: if whole { pm.data_as_u8_slice().to_vec() } else { Vec::new() },
fx: chain.iter().map(|fx| (*fx).clone()).collect(),
size: content.reach.size(),
channels: Some(channels),
@@ -1842,6 +1928,14 @@ impl Renderer {
}
}
let image = std::sync::Arc::new(pm);
// A raster kept for this view alone at a lower resolution than the view's is resampled to the
// view's pixels once: each frame that draws it then copies it pixel for pixel, where sampling
// it again across the whole view cost several times as much as a plain fill.
let view_raster = key.is_some() && in_view.is_some() && k < 1.0 && content.kept.is_none();
let (image, x0, y0, k) = match view_raster.then(|| self.resampled(f, image.clone(), (x0, y0), k, screen)).flatten() {
Some((image, x, y)) => (image, x, y, 1.0),
None => (image, x0, y0, k),
};
// A cancelled export drew part of the content: that raster isn't kept for later frames.
if !vectorcraft_doc::cancel::is_cancelled()
&& let Some(((key, made), inputs)) = key.zip(made).zip(inputs)
@@ -1851,7 +1945,8 @@ impl Renderer {
let rel = (x0 - p.x, y0 - p.y);
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
let node = Drawn::of(content.node, owner.as_ref());
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, failed, stamp });
let entry = PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, stamp };
self.pixel_fx.insert(key, entry);
self.trim_fx_rasters(FX_RASTER_BUDGET);
}
// An image over the limits was drawn at a frame's resolution: its raster is kept.
@@ -1865,6 +1960,33 @@ impl Renderer {
}
}
/// `image` as [`draw_scaled`] draws it with its top-left at (`x`, `y`) device pixels, `1 / k`
/// device pixels a pixel, resampled to the device pixels it covers of `screen`, with their
/// top-left. `None` when it covers none of them.
fn resampled(
&mut self,
f: &Frame,
image: std::sync::Arc<vello_cpu::Pixmap>,
(x, y): (f64, f64),
k: f64,
screen: Rect,
) -> Option<(std::sync::Arc<vello_cpu::Pixmap>, f64, f64)> {
let covered = Rect::new(x, y, x + f64::from(image.width()) / k, y + f64::from(image.height()) / k).intersect(screen);
let (x0, y0) = (covered.x0.floor(), covered.y0.floor());
let (w, h) = (covered.x1.ceil() - x0, covered.y1.ceil() - y0);
let side = f64::from(crate::MAX_RASTER_SIDE);
if !((1.0..=side).contains(&w) && (1.0..=side).contains(&h)) {
return None;
}
let (w, h) = (w as u16, h as u16);
let mut off = f.offscreen_context(w, h);
draw_scaled(&mut off, image, (x - x0, y - y0), k);
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
off.render(&mut pm, &mut self.resources);
Some((std::sync::Arc::new(pm), x0, y0))
}
/// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame
/// made at full size for its object (the same id, slot and ink, at this zoom and rotation, with
/// the same `inputs`) when only the content's place changed after that frame: made by the same
@@ -1895,6 +2017,7 @@ impl Renderer {
&& e.stamp < stamp
&& e.linear == linear
&& e.k >= 1.0
&& e.view.is_none()
&& e.node.id() == content.node.id
&& e.inputs == *inputs
&& e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref())
@@ -2369,6 +2492,45 @@ mod tests {
assert!(r.render(&d, 400, 400, Affine::scale(2.0), &opts).pixels == first.pixels, "made again alike");
}
/// An Outer Glow whose whole reach is over the budget is made coarser over that whole reach and
/// kept for any pan: its grid starts at the reach's first whole device pixel, which a pan by whole
/// pixels moves with the art, so the raster drawn after the pan is the one a fresh renderer makes,
/// also for pans that aren't multiples of the raster's scale. A pan by a fraction of a pixel
/// places the raster at the nearest whole pixel, as a full-size raster kept whole is placed, and
/// here draws as a fresh renderer draws too. A 100 pt glow at 500% (scale 2) and a 2 pt glow
/// round the page at 800% (scale 3, near the narrowest blur made coarser).
#[test]
fn a_coarser_glow_kept_whole_draws_after_a_pan_as_made_again() {
let size = (640, 360);
for (rect, blur, zoom, center, scale) in [
(Rect::new(200.0, 300.0, 400.0, 500.0), 100.0, 5.0, (440.0, 400.0), 2.0),
(Rect::new(0.0, 0.0, 595.0, 842.0), 2.0, 8.0, (595.0, 421.0), 3.0),
] {
let mut d = Document::new(595.0, 842.0);
let layer = d.layers[0].id;
let mut n =
Node::path(d.alloc_id(), shapes::rectangle(rect), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.3, 0.9)), Paint::None, 0.0));
n.appearance.effects = vec![Effect { id: "stylize.outerGlow".into(), params: json!({"blur": blur}), visible: true }];
d.insert(Some(layer), 0, n).unwrap();
let base = Affine::translate((320.0, 180.0)) * Affine::scale(zoom) * Affine::translate((-center.0, -center.1));
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, size.0, size.1, base, &opts);
let image = |r: &Renderer| r.shadows.values().map(|e| std::sync::Arc::as_ptr(&e.image)).collect::<Vec<_>>();
let kept = image(&r);
assert!(r.shadows.values().all(|e| e.scale == scale && e.view.is_none()), "blur {blur}: coarser, over the whole reach");
for pan in [(37.0, -21.0), (1.0, 0.0), (-3.0, 5.0), (0.5, 0.0), (37.3, -21.6)] {
let xf = Affine::translate(pan) * base;
let panned = r.render(&d, size.0, size.1, xf, &opts);
assert_eq!(image(&r), kept, "blur {blur}, pan {pan:?}: the raster kept is drawn");
let mut fresh = Renderer::new();
fresh.threads = 2;
assert!(panned.pixels == fresh.render(&d, size.0, size.1, xf, &opts).pixels, "blur {blur}, pan {pan:?}");
}
}
}
/// The multithreaded rasterizer draws the same pixels with any number of workers, so the
/// automatic thread count can follow the machine.
#[test]
@@ -2589,6 +2751,21 @@ mod tests {
assert!(faint[1] < 210, "the glow reaches past the edge: {faint:?}");
}
#[test]
fn an_outer_glow_grows_its_silhouette_by_a_quarter_raster_pixel() {
// GUARDS: the glows AI files export measure as the solid silhouette grown by a quarter of a pixel
// of the document's raster grid, then blurred: a quarter point at 72 ppi, next to nothing at 720.
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
let green_outside = |ppi: f64| {
let mut d =
doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]);
d.raster_effects_ppi = ppi;
render(&d).pixel(27, 50)[1]
};
let (coarse, fine) = (green_outside(72.0), green_outside(720.0));
assert!(coarse + 3 < fine, "the red glow is stronger at 72 ppi: 72 ppi {coarse}, 720 ppi {fine}");
}
#[test]
fn a_glow_glows_round_the_glows_before_it_in_the_stack() {
// GUARDS: the second glow is of the art with the first glow, not of the bare art, as AI files and the SVG export draw it.
@@ -2836,8 +3013,9 @@ mod tests {
/// While dragging (#2558), filtered content and a drop shadow that the frame does not draw from a
/// raster of an earlier frame render within the drag's budget and keep no raster. At 6400% a
/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so no raster is
/// kept for either, and a 1600 × 1000 view near its corner needs more than the drag's budget. The
/// rectangle with Drop Shadow and Texturizer covers more than the raster budget, so the still
/// frame keeps rasters cropped to its view, which the moved rectangle can't take along, and a
/// 1600 × 1000 view near its corner needs more than the drag's budget. The
/// frame after the drag, of the same document, is what a fresh renderer draws, as is a frame
/// rendered while interacting without a drag (a dialog's preview).
#[test]
@@ -2867,7 +3045,7 @@ mod tests {
let dragged = r.render(&d, 1600, 1000, view, &drag).pixels;
assert_eq!(r.stats.previewed, 2, "the shadow and the filtered content");
assert_eq!(r.stats.moved, 2, "counted as moved: the frame after the drag renders them again");
assert!(r.pixel_fx.is_empty() && r.shadows.is_empty(), "no raster kept");
assert!(r.pixel_fx.is_empty() && r.shadows.values().all(|e| e.stamp < r.stamp), "no raster kept");
assert!(dragged != fresh, "at the drag's lower resolution");
let after = r.render(&d, 1600, 1000, view, &still).pixels;
assert_eq!((r.stats.previewed, r.stats.moved), (0, 0));
+8 -3
View File
@@ -410,8 +410,9 @@ type ClipEntry = (Arc<Node>, Option<Arc<(BezPath, FillRule)>>);
/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]).
type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>>);
/// An object with effects (kept alive), the art drawn for it ([`Renderer::fx_art`]), and, when that
/// art can be made from evaluated live objects (`fx::evaluates_live`), the inputs of their
/// evaluation ([`live::Found`]).
/// art can be made from evaluated live objects (`fx::evaluates_live`) or from type, symbol art or
/// colors shaded through the color settings (`fx::reads_outside`), the inputs it was made with
/// ([`live::Found`]).
type FxArtEntry = (Arc<Node>, Arc<Node>, Option<live::Found>);
/// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits
/// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]).
@@ -1222,7 +1223,9 @@ impl Renderer {
/// In isolation mode, while drawing outside the isolated container and the art dimmed around
/// it: the isolated container and its layers and groups, from the top layer down to it.
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
// Opacity-mask editing isolates the temporary layer for tools; that layer is not painted on
// the page (the mask applies on the object), so canvas dimming would wash out everything.
let isolated = f.opts.isolated.filter(|_| f.doc.mask_edit.is_none() && !self.isolation_settled)?;
let doc = std::ptr::from_ref(f.doc) as usize;
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
@@ -2525,3 +2528,5 @@ mod tests_setup;
mod tests_strokegradient;
#[cfg(test)]
mod tests_tileedge;
#[cfg(test)]
mod tests_viewraster;
+3 -3
View File
@@ -68,8 +68,8 @@ pub fn expand_live(n: &Node) -> Vec<Node> {
crate::effects::expand_live(None, n)
}
/// What the evaluation of `n` (a live node, or an object whose effect art can be made from
/// evaluated live objects, see [`Renderer::live_found`]) reads from the document, placed files,
/// What the evaluation of `n` (a live node, or an object whose effect art the renderer keeps with
/// its inputs, see [`Renderer::live_found`]) reads from the document, placed files,
/// the fonts, the plug-ins and the color settings, hashed: the art of the symbols, the tilings of
/// the patterns and the reads of the placed files it shows ([`hash_resources`]), the font
/// generation ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, the
@@ -343,7 +343,7 @@ fn spread_to_edges(px: &mut [vello_cpu::color::PremulRgba8], filled: Vec<bool>,
impl Renderer {
/// The [`resources_key`] of `n` in `doc`, with the frame's [`DocInputs`] ([`Found`]): `n` is a
/// live node, or an object with effects whose art ([`Renderer::fx_art`]) can be made from
/// evaluated live objects.
/// evaluated live objects, type, symbol art or colors shaded through the color settings.
pub(crate) fn live_found(&mut self, doc: &Document, n: &Node) -> Found {
let inputs = self.doc_inputs(doc);
let (key, reads) = resources_key(doc, inputs.cms, n);
+54
View File
@@ -155,6 +155,60 @@ fn symbol_type_redraws_when_its_font_becomes_available() {
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type that a geometry effect outlines redraws in a font that becomes available: the art drawn for
/// the effects of the type, of a group that holds it and of a symbol instance whose art holds it
/// is made again when the fonts change.
#[test]
fn type_with_geometry_effects_redraws_when_its_font_becomes_available() {
let _adding = adding_fonts();
use vectorcraft_doc::Effect;
let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true };
let cases = [
("Moving Serif 4", "the type with a Transform effect"),
("Shadow Serif 5", "the type with a Transform effect and a drop shadow"),
("Massed Serif 4", "the group with a Transform effect"),
("Stamps Serif 4", "the symbol instance with a Transform effect"),
("Darker Serif 4", "the type with a Transform effect in Multiply"),
];
for (i, (family, what)) in cases.into_iter().enumerate() {
let mut d = Document::new(300.0, 150.0);
let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() };
let at = if i == 3 { Point::new(-140.0, 20.0) } else { Point::new(10.0, 80.0) };
let mut object = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(at, "Hamburg", style))));
match i {
0 => object.appearance.effects.push(moved()),
1 => {
object.appearance.effects.push(moved());
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
}
2 => {
object = Node::group(d.alloc_id(), vec![Arc::new(object)]);
object.appearance.effects.push(moved());
}
3 => {
let art = Node::group(d.alloc_id(), vec![Arc::new(object)]);
d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) });
object = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 75.0)) });
object.appearance.effects.push(moved());
}
_ => {
object.appearance.effects.push(moved());
object.blend = vectorcraft_color::BlendMode::Multiply;
}
}
let l = d.layers[0].id;
d.insert(Some(l), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(family)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "{what}: drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it");
}
}
/// Type that an envelope bends redraws in a font that becomes available, in the envelope and in
/// the art of a symbol whose instance an envelope bends: the envelopes are evaluated again when the
/// fonts change.
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more