Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ad0566943a Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 22:18:48 -04:00
BFlat 9e7929ee89 Merge pull request #3173 from KaboomFox/p6-effect-paint
Plug-ins: live effects read their object's fills and strokes (G17)
2026-10-20 08:49:13 +07:00
Louise FoxandClaude Opus 5.5 2e4666a364 Plug-ins: live effects read their object's fills and strokes (G17)
A live-effect plug-in was given only its object's geometry. An effect
that draws as wide as the stroke it sits on, or tells its fills apart,
had to ask for them again as parameters, which then went stale when
the paint changed.

A live effect's input object now holds the object's fills and strokes
too, read only and in the form a filter gets them (one encoder,
objects::paint_lists, for both), and _context.item says where the
effect sits: "object", or {"fill": i} / {"stroke": i} for an effect in
a fill's or a stroke's own effects. effects::GeomContext carries the
object's appearance and the item, and the plug-in cache keys on the
paints, so a change of paint runs the plug-in again. Hosts from before
this give geometry only; nothing in the output changes.

Expand worked a fill's geometry effects out in the object's context, so
a plug-in effect there would be told "object" and a gradient fill was
expanded into a shape the canvas doesn't draw: fill_shape now gives them
the fill's context, as drawing does (expand.rs).

Siblings named with a declared prefix, the other half of such context,
are left out: the effected geometry is worked out from the object alone
wherever it is drawn, measured, hit-tested or expanded.

wat::when_given is a plug-in whose output depends on whether its input
or parameters hold a text. tests_plugins.rs uses it to check that an
effect sees the stroke's width and the fill, runs again for a thinner
stroke, and on a second stroke is told so; effect.rs checks the item
counting; a gradient fill under such an effect is drawn and expanded
into the same triangle. CRAFT_PLUGIN_PAINTS_WASM=<folder> saves a test
effect to try in the app.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-20 08:39:41 +07:00
BFlat ee4564c586 Merge pull request #2954 from nrq6993-a11y/stream/vc-feat-g2-sketch-note-paper
G2: Effect › Sketch › Note Paper
2026-10-20 08:39:16 +07:00
bflatastic c9d769ad4e Note Paper: grey tones on white like the other Sketch filters, rustfmt (#2954) 2026-10-20 08:30:05 +07:00
bflatastic 62e8474caa i18n and docs: Note Paper's catalog rows, raster effects 46 of 57 (#2954) 2026-10-20 08:29:26 +07:00
nrq6993-a11y 301783c480 G2: Effect › Sketch › Note Paper raster filter (#2954) 2026-10-20 08:28:51 +07:00
BFlat 64699f5212 Merge pull request #3172 from KaboomFox/p2-rich-params
Plug-ins: labels, units, groups, help and conditions for parameters (G17)
2026-10-20 08:27:36 +07:00
BFlat bea46e63ff Merge pull request #3171 from KaboomFox/p1-exporter-plugins
Plug-ins: exporters write file formats of their own (G17)
2026-10-20 08:27:26 +07:00
Louise Fox c755909d67 Plug-ins: labels, units, groups, help and conditions for parameters (G17)
A plug-in's dialog labelled each field with its parameter's name spaced
out ("Line_spacing_mm:"), showed numbers without their unit and listed
at most 64 parameters in one column. A plug-in with many settings, such
as a generator's or an exporter's, could neither explain nor sort them,
and its dialog showed settings that don't apply to the choices made.

Each parameter may now say how dialogs show it, in keys that hosts
from before them ignore (plugins::dialog):

- label; unit, after the value ("0.25 mm"); help, on the label; and
  helpUrl, an https:// page opened from a button after the field.
- group: the parameters outside any group come first, then each group
  as a section that folds, in the order the groups first appear, the
  fields lined up down the dialog.
- visibleWhen: {"pattern": ["lines"]} shows a parameter only
  while each checkbox or choice it names, declared before it, has one
  of the values listed and is shown itself. Manifest::visible decides
  every parameter in one pass, in declaration order. A hidden parameter
  still goes to the plug-in with its value, so a run never depends on
  what the dialog showed.

Two types join number, int, bool and choice: string, a text field cut
to maxLength, and numberList, numbers typed separated by commas or
spaces, each clamped, minItems to maxItems of them. A plug-in declares
up to 256 parameters, at most 64 outside groups. The filter, live-effect
and exporter dialogs use every key. The Appearance panel's editor of a
plug-in effect uses the labels, units, help and conditions, and reads a
typed number list as the dialog does.

dialog.rs tests parsing, the round trip of a manifest written out,
sections, visibility (also of 256 overlapping conditions, which a
recursive check would take forever on) and the keys refused;
manifest.rs the new types and limits. tests_plugin_fields.rs draws a
dialog with kittest: labels and units, sections in order with their
fields lined up, visibleWhen hiding and showing rows, and the help
button opening its page.
appearance.rs checks the editor's labels, units, conditions and a
typed number list, and form.rs units after numbers (a unit starting
with a digit is spaced from it) and typed lists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un

# Conflicts:
#	docs/gaps.md
#	docs/target-app-parity.md
2026-10-20 08:17:53 +07:00
Louise FoxandClaude Opus 5.5 30f372a220 Contributors: credit KaboomFox by their GitHub profile name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-20 08:17:48 +07:00
Louise FoxandClaude Opus 5.5 24936cd006 Plug-ins: exporters write file formats of their own (G17)
A plug-in could filter objects or reshape them as a live effect, but
nothing it made could leave the app: pen plotters, cutting machines and
laser cutters read formats VectorCraft doesn't write. An exporter is a
third kind of plug-in ("kind": "exporter", with the file "extensions"
it writes), in the same sandbox and with the same ABI v1 exports.

- It adds a write-only format, plugin.<id>. document.formats lists it
  with its plug-in id, and document.export writes it by that id, or by
  a path ending in one of its extensions that no built-in format has;
  "params" carries its parameters, checked against its manifest as a
  filter's are.
- File › Export › Export As lists it after the built-in formats, with
  Use Artboards greyed out, since the plug-in writes the whole
  document. One with parameters asks for them in a dialog generated
  from its schema (pluginExport) once the file is picked.
- The plug-in is given the native file's JSON (format::json_part:
  images of 8 KiB or more without their data) and _context {mode:
  "export", extension, name}. It returns {"dataBase64", "warnings"?}
  or {"error"}. The host writes the file, and nothing when the plug-in
  declines or returns bad data.
- An export gets 10 G instructions plus 4,000 per byte and 300 s.
  Background Export's Cancel stops it between two slices of that
  budget (Error::Cancelled), and a cancelled live-effect run is not
  cached as a failure.
- plugin.run and Object › Plug-ins say where an exporter is instead of
  calling it a live effect.

engine/tests/plugin_export.rs reads back what an exporter is handed
with wat::echo_exporter, an exporter whose file is its input or its
parameters (its base64 encoder is written in WAT). The document it is
given is document.save's JSON, without the large image's data, and its
parameters arrive defaulted, clamped and with the file's name and
extension. It also covers listing and removal, built-in extensions
winning, selectedOnly, declines, bad base64, missing data, warnings,
and Cancel stopping a plug-in that never returns. It is a test binary
of its own: installed plug-ins are process-wide, and the format-list
unit tests check exact lists. dialogs/tests_plugin_export.rs drives
Export As headlessly; plugins/tests/abi.rs covers the budget, the
parameters and the echo exporter; format/tests/native_v4.rs covers
json_part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-20 08:17:48 +07:00
BFlat 1dda8d9e67 Merge pull request #2268 from danielmigueltejedor/fix/gnome-window-decorations
Fix GNOME/Wayland window controls
2026-10-20 07:40:12 +07:00
bflatastic 20557277c2 Packaging: package.sh stays executable (#2268) 2026-10-20 07:34:47 +07:00
Daniel 050f68a030 fix(linux): enable font-free Wayland window controls 2026-10-20 07:34:46 +07:00
BFlat 39fdaf7db8 Merge pull request #2565 from dotCooCoo/feat/artboard-size-applies-to-selected
Artboards: Width, Height, Preset and Orientation apply to every selected artboard
2026-10-20 07:32:56 +07:00
BFlat 2544d7f090 Merge pull request #3087 from Zaxaerith/docs/2980-typography-lettering
docs: add typography and custom lettering guide (#2980)
2026-10-20 07:32:40 +07:00
BFlat fd69aa0623 Merge pull request #3029 from Zaxaerith/docs/tiled-printing-guide-2857
docs: make tiled poster printing discoverable (#2857)
2026-10-20 07:32:24 +07:00
BFlat 5dd86dc19c Merge pull request #2408 from dotCooCoo/feat/paste-ai-editing-data
Clipboard: a copied .ai or .ait file pastes the objects of its editing data
2026-10-20 07:32:09 +07:00
dotCooCoo 7617fcd536 Artboards: Width, Height, Preset and Orientation apply to every selected artboard (#2565) 2026-10-20 07:23:49 +07:00
Zaxaerith c3083e245d docs: add typography and custom lettering guide (#2980) 2026-10-20 07:23:47 +07:00
Zaxaerith 0688ad04d7 docs: document tiled poster printing (#2857) 2026-10-20 07:23:47 +07:00
dotCooCoo 89986d01bf Clipboard: a copied .ai or .ait file pastes the objects of its editing data (#2408) 2026-10-20 07:23:40 +07:00
BFlat 4cd77a7a18 Merge pull request #3110 from KnottyDyes/squad/cli-install-docs
Docs: install the CLI and register it with MCP clients
2026-10-20 07:19:18 +07:00
BFlat 19efa9ea2b Merge pull request #2575 from 00xJS/fix/2213-large-image-clipboard-text
Fix #2213: a copy with a large image no longer puts its SVG on the clipboard as text
2026-10-20 07:18:59 +07:00
KnottyDyes 371d0de798 Docs: install the CLI and register it with MCP clients 2026-10-20 07:10:21 +07:00
00xJ0 548ace24d2 Clipboard: a copy with a large image no longer puts its SVG on the clipboard as text (fixes #2213)
With Include SVG Code on, Copy offered the SVG markup as plain text with every placed image
embedded as base64: a 13 MB photo made 17 MB of text. A paste reads the text first (eframe on
every paste key; on Linux and macOS it is the one format written, and holds_ours reads it again),
so each paste moved it through the system clipboard on the UI thread, and on GNOME the app
stopped responding.

The SVG markup now goes out as text only while the images it embeds total at most 1 MiB; above
that it is offered as image/svg+xml and PNG. Type keeps its text, and vector art keeps its SVG
text at any size (only embedded image bytes count).

Windows 11, Ryzen 5 2600X, 13 MB PNG, real Ctrl+C: the longest UI stall fell from 823-1021 ms
to 133 ms. Not run on Linux.
2026-10-20 07:10:21 +07:00
BFlat a13968e821 Merge pull request #3050 from nrq6993-a11y/stream/vc-feat1-g1-group-selection-shift-deselect
G1: Group Selection Shift-click deselects compound path members
2026-10-20 07:10:07 +07:00
nrq6993-a11yandCursor e2e2ac12a9 Group Selection: fold MoveObject pointer-up into shared arm
Read deselect before idle reset and handle shift-deselect via select.toggle in the shared Up match.

Co-authored-by: Cursor <[email protected]>
2026-10-20 07:00:49 +07:00
nrq6993-a11yandCursor 61f35cd037 G1: Group Selection Shift-click deselects compound path members
Match the Selection tool: Shift on an already selected hit removes it on
release without a drag, including one path of a compound path.

Co-authored-by: Cursor <[email protected]>
2026-10-20 07:00:49 +07:00
BFlat 891793b3a4 Merge pull request #3167 from sungjea713/assets-tab-scrolls-to-checked
Export for Screens: the Assets tab scrolls to the assets it opens on
2026-10-20 07:00:28 +07:00
sungjea kim 6b24e3cd6a Export for Screens: the Assets tab scrolls to the assets it opens on
Export Selection… and the Asset Export panel open Export for Screens on the
Assets tab with the new assets checked, but new assets land at the end of
the list, so in a document with many assets the checked one sat below the
list's visible part and had to be scrolled to by hand. The list now scrolls
to the first checked asset when the dialog opens on them. It keeps asking
until the row is in view (the dialog's first frame only measures it), then
stops, so the list scrolls freely afterwards.
2026-10-20 06:54:13 +07:00
BFlat 02e12c6935 Merge pull request #2533 from vangel76/feat/2527-sketch-filters
Effects: Sketch › Bas Relief, Charcoal, Photocopy and Stamp (#2527)
2026-10-20 06:53:21 +07:00
Martin Vlach f664564e97 Effects: Sketch › Bas Relief, Charcoal, Photocopy and Stamp (#2527)
Squashed from #2533 onto #2525 (Smudge Stick, Sponge, Watercolor): both sets in PIXEL_EFFECTS (45), the catalogs and the docs, whose counts now read 45/57.
2026-10-20 06:43:50 +07:00
BFlat d14b67e015 Merge pull request #2525 from vangel76/feat/2435-artistic-filters
Effects: Artistic: Smudge Stick, Sponge and Watercolor (#2435)
2026-10-20 06:41:08 +07:00
Martin Vlach 5251246778 Effects: Artistic: Smudge Stick, Sponge and Watercolor (#2435)
Squashed from #2525 (its merges of main dropped).
2026-10-20 06:32:32 +07:00
BFlat 1097ab190d Merge pull request #3166 from sungjea713/export-selection-single-asset
Export Selection: the selection becomes one asset, not one per object
2026-10-20 06:32:06 +07:00
sungjea kim 17ba27a0f6 Export Selection: the selection becomes one asset, not one per object
File › Export Selection… collected the selected objects one asset per
object, so a few ungrouped shapes picked to export together came out as
separate images unless they were grouped first. It now collects them as one
asset of them all (`assets.add {multiple: false}`), the selection exported
as one image; `file.exportSelection {multiple: true}` still makes one per
object, and Object › Collect for Export › As Multiple Assets and the Asset
Export panel's Multiple Assets item are unchanged.
2026-10-20 06:26:11 +07:00
BFlat 4322c825a5 Merge pull request #3057 from nrq6993-a11y/stream/vc-feat-g1-grid-ref-point
G1: Rectangular and Polar Grid dialogs reference point locator (#1377)
2026-10-20 06:14:43 +07:00
bflatastic ddab0152af fmt (#3057) 2026-10-20 06:08:16 +07:00
nrq6993-a11yandCursor f12ad3aa0a G1: Pin grid dialog bounds to canvas click via reference locator (#1377)
Store the click as __anchorX/__anchorY and recompute x/y when the locator opens or changes so the chosen point stays on the click like Illustrator.

Co-authored-by: Cursor <[email protected]>
2026-10-20 06:07:29 +07:00
nrq6993-a11yandCursor 699e6e9af4 G1: Grid tool dialogs add reference point locator (#1377)
Rectangular and Polar Grid click dialogs show the nine-point locator and resize width/height about it using the same artboard_rect logic as Artboard Options.

Co-authored-by: Cursor <[email protected]>
2026-10-20 06:07:29 +07:00
BFlat 059b6d083c Merge pull request #3165 from danngreen/fix/2143-guide-key-object
Fix #2143: ruler guides and key objects align to each other
2026-10-20 06:07:00 +07:00
Dan Green 7852f363a1 Fix #2143: selected ruler guides align to a key object
A lone object selected with ruler guides can be the key (the Selection
tool's click on it), and Align to the key moves the other selected
guides to its edge or centre: vertical guides to left, centre or right,
horizontal ones to top, centre or bottom. Parallel guides line up on a
key guide. Aligning to the selection or the artboard leaves guides alone.
2026-10-20 05:56:59 +07:00
Dan Green 263c36567f Fix #2143: a ruler guide can be the key object Align lines objects up on
A click with the Selection tool on a guide selected with objects makes
it the key (select.key {guide}), drawn thicker; a click on it again
cancels it. Align to Key Object is on with one object and a selected
guide and makes the guide the key. object.align lines the objects (or
selected anchors) up on the guide's line and leaves the guide where it
is: a vertical guide sets x only, a horizontal one y. The selection
keeps key_guide beside key, never both; it goes with the guide or the
objects, and follows the guide when earlier guides are deleted.
Distribute Spacing doesn't space from a key guide.
2026-10-20 05:56:58 +07:00
BFlat 398d2e19de Merge pull request #3163 from Matoo125/fix/polygon-sides-from-file
Live shapes: a polygon read from a file has 3 to 1000 sides
2026-10-20 05:45:38 +07:00
BFlat 0567752448 Merge pull request #3162 from danngreen/feat/libraries-panel-scroll-reorder
Libraries panel: scrolling, and drag to reorder items and groups
2026-10-20 05:36:04 +07:00
Martin Vlach 38e73060a7 Live shapes: a polygon read from a file has 3 to 1000 sides
Every command keeps a live polygon's side count in 3..=1000
(shape.polygon, object.setLiveShape, the Polygon tool), but reading a
document took `sides` as any u32. Selecting such a polygon builds its
Live Corners: LiveShape::polygon_outline makes one point per side and
set_corners resizes the corner radii and kinds to the side count. A
document a few bytes long ("sides": 4000000000) makes the app allocate
tens of gigabytes and abort or stall, and the unsaved work in other
documents is lost with it. Transforming such a polygon with cut
corners regenerates the same outline.

`sides` is now clamped to 3..=1000 when it is deserialized, so a
polygon from any document that is read (opened, placed, packaged or
taken from a library) keeps the limit the commands keep, and the code
that uses the count needs no check of its own.

The test reads polygons with 0, 5, 1000 and 4,000,000,000 sides,
checks they come out with 3, 5, 1000 and 1000, and that the clamped
one's outline has 1000 anchors.

Reported-by: BS.ehlas.cz
2026-10-20 05:28:32 +07:00
Dan Green 6bed87b5fb Libraries panel: the list scrolls; items and groups reorder by dragging, items drag into groups
The list fills the panel down to its bottom bar and scrolls, also while a drag nears its top or
bottom edge (in the dock and in a flyout, as the Layers panel does).

Dragging in the list:
- an item (colour, text style or graphic) dropped on another of its kind lands just before or
  after it, by the pointer's half, an accent line showing where, and joins that item's group;
- dropped elsewhere on a group (its header too, even collapsed) it goes into that group, and on
  the ungrouped items out of its group, the section outlined; with every item grouped, a
  "Drop here to take it out of its group" strip shows while one is dragged;
- a group dragged by its header goes before or after another group.
Colours and graphics still drag onto art and the canvas as before.

Engine: library.moveItem takes `before` / `after` (an item of the same kind) to place the item
in its kind's order, which the panel shows; the new library.moveGroup {group, to} moves a group.
Text styles drag as PanelDrag::LibraryItem, groups as PanelDrag::LibraryGroup, and a library
colour's Paint drag names its library item, so the panel tells it from a swatch dragged in.

Tests: reordering and moving in the engine, the commands in the library fuzz test, and panel
tests that drop styles and group headers and check the list scrolls and scrolls while dragging.
Strings translated in the 23 catalogs; docs/mcp.md and the parity row updated.
2026-10-20 05:28:17 +07:00
BFlat 6f3c974984 Merge pull request #3056 from nrq6993-a11y/stream/vc-feat1-g5-art-brush-overlap
G5: Art brush Overlap option in Brush Options
2026-10-20 05:17:39 +07:00
bflatastic cade497b79 fmt (#3056) 2026-10-20 05:06:37 +07:00
nrq6993-a11yandCursor feda1c062f G5: Add Overlap to complete i18n catalogs for art brush options.
Co-authored-by: Cursor <[email protected]>
2026-10-20 05:05:51 +07:00
nrq6993-a11yandCursor b929561fc3 G5: Art brush Overlap option bends art per corner run
Co-authored-by: Cursor <[email protected]>
2026-10-20 05:05:50 +07:00
BFlat 4be63905b1 Merge pull request #2990 from nrq6993-a11y/stream/vc-feat-g2-sketch-plaster
G2: Effect › Sketch › Plaster raster filter
2026-10-20 05:04:06 +07:00
bflatastic 8a7a1cf199 i18n: Plaster in every catalog that has the Sketch filters (#2990) 2026-10-20 04:52:37 +07:00
MarekSpodBiedry b560b97616 G2: Effect › Sketch › Plaster raster filter
Squashed from #2990 onto Chrome.
2026-10-20 04:52:26 +07:00
BFlat d21f3e0667 Merge pull request #3159 from odgrim/fix/pdf-import-invisible-text
Keep a PDF's invisible text (a scanned page's OCR layer) as type, and save it invisible (#2675)
2026-10-20 04:50:38 +07:00
Brian TorresandClaude Opus 5.5 ac7f0eebf8 PDF import and export: keep invisible text (rendering mode 3) as type (#2675)
Text drawn in rendering mode 3 (3 Tr), which is how a scanned page carries
its recognized OCR text, was left out on open (since #2988, with a warning),
so a scanned PDF opened and saved again lost its searchable text.

Import: an invisible glyph now comes in as type with neither fill nor
stroke. It skips the glyph-outline checks that guard how visible type looks,
and it joins only other invisible text on a line (Look::takes no longer
lets it into a stroke-only run). INVISIBLE_TEXT_NOTE now only reports text
that can't be type (text opened as outlines, Type 3 fonts), and
tests_reader_compat::invisible_text_is_reported_when_left_out expects the
invisible line as unpainted type when text opens as type.

Export and print: characters with no fill and no stroke are written as real
text inside a form marked Mark::Invisible; forms::finish sets that form's
text rendering mode to 3, so it draws nothing but stays selectable and
searchable (PDF/X-1a and PDF/A-2b too). The writer has no invisible mode of
its own, so this reuses the marked-form mechanism used for layers and
overprint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 04:43:11 +07:00
BFlat 498d17f72e Merge pull request #2985 from nrq6993-a11y/stream/vc-feat-g2-sketch-chrome
G2: Effect › Sketch › Chrome raster filter
2026-10-20 04:32:36 +07:00
bflatastic 326b1c3139 i18n: Chrome in every catalog that has the Sketch filters (#2985) 2026-10-20 04:21:13 +07:00
MarekSpodBiedry 2ff683d5fe G2: Effect › Sketch › Chrome raster filter
Squashed from #2985.
2026-10-20 04:20:50 +07:00
BFlat fd780c51b0 Merge pull request #3156 from MarekSpodBiedry/issue-3126
Draw the isolation bar over the canvas instead of squeezing it (#3126)
2026-10-20 03:51:47 +07:00
BFlat 1b6552a4fa Merge pull request #3157 from MarekSpodBiedry/issue-3135
Add a generic family to the font-family of SVG export and CSS (#3135)
2026-10-20 03:51:31 +07:00
BFlat 8918747a24 Merge pull request #3158 from MarekSpodBiedry/issue-3141
Ask for the .ai options on Save a Copy like Save As (#3141)
2026-10-20 03:51:22 +07:00
MarekSpodBiedry ef99948cd5 Draw the isolation bar over the canvas instead of squeezing it (#3126) 2026-10-20 03:42:11 +07:00
bflatastic 04139ad0b6 fmt (#3157) 2026-10-20 03:42:11 +07:00
MarekSpodBiedry 0f855be77f Add a generic family to the font-family of SVG export and CSS (#3135) 2026-10-20 03:41:17 +07:00
MarekSpodBiedry 7c47692b32 Ask for the .ai options on Save a Copy like Save As (#3141) 2026-10-20 03:41:16 +07:00
BFlat 8a6e4e77f3 Merge pull request #2997 from nrq6993-a11y/stream/vc-feat-g1-guide-alt-orientation-move
G1: Alt-drag an existing ruler guide to swap horizontal and vertical
2026-10-20 03:29:19 +07:00
BFlat 686da2dce7 Merge pull request #3155 from storytold/docs/effects-parity-2026-10-19
Docs: effects parity counts the Sketch and Artistic filters of 2026-10-19 (36/57)
2026-10-20 03:25:23 +07:00
BFlat 68b1f7a07c Merge pull request #3152 from Silvertree2010/fix/h652-svg-export-writes-invalid-xml-wh
Filter control characters in SVG export title and description
2026-10-20 03:25:07 +07:00
nrq6993-a11yandCursor 2ceb183bfe G1: restore Alt-drag copy for existing ruler guides (#2997).
Drop the undocumented orientation-swap on moved guides; Alt again previews guide.move with copy, matching new-guide Alt orientation only.

Co-authored-by: Cursor <[email protected]>
2026-10-19 23:17:28 +03:00
bflatastic fe6ef8ade7 Docs: effects parity counts the four Sketch and Artistic filters of 2026-10-19 (36/57) 2026-10-20 03:16:37 +07:00
NetterSilverandClaude Haiku 5.5 05017111ba Filter control characters in SVG export title and description
Found while testing crates/svg/src/export.rs:138 and :143

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-20 03:15:47 +07:00
BFlat 5397ad22e7 Merge pull request #3153 from storytold/fix/sketch-filter-rows
Effects: catalog rows for Chalk & Charcoal, Graphic Pen, Reticulation and Plastic Wrap (#2972, #2967, #2976, #3034)
2026-10-20 03:14:38 +07:00
bflatastic a5ec99d515 Effects: Graphic Pen's params doc names no vendor, as cargo xtask brands asks (#2967) 2026-10-20 03:14:27 +07:00
bflatastic 92c1657334 i18n: Chalk & Charcoal, Graphic Pen, Reticulation and Plastic Wrap in every catalog that has the Sketch filters (#2972, #2967, #2976, #3034)
Also cargo fmt over the four filters.
2026-10-20 03:14:27 +07:00
BFlat 8f481aa2f4 Merge pull request #3034 from nrq6993-a11y/stream/vc-feat3-g2-artistic-plastic-wrap
G2: Effect › Artistic › Plastic Wrap raster filter
2026-10-20 03:14:22 +07:00
BFlat 7616bb9221 Merge pull request #2976 from nrq6993-a11y/stream/vc-feat-g2-sketch-reticulation
G2: Effect › Sketch › Reticulation raster filter
2026-10-20 03:14:06 +07:00
BFlat 1f579352ed Merge pull request #2967 from nrq6993-a11y/stream/vc-feat-g2-sketch-graphic-pen
G2: Effect › Sketch › Graphic Pen raster filter
2026-10-20 03:13:50 +07:00
BFlat 851eb7f076 Merge pull request #2972 from nrq6993-a11y/stream/vc-feat-g2-sketch-chalk-charcoal
G2: Effect › Sketch › Chalk & Charcoal raster filter
2026-10-20 03:13:32 +07:00
nrq6993-a11y a2c764d312 G2: Effect › Artistic › Plastic Wrap raster filter
Squashed from #3034 onto the Sketch filters; the Artistic label test lists it.
2026-10-20 02:55:29 +07:00
nrq6993-a11y 917aa29dd5 G2: Effect › Sketch › Reticulation raster filter
Squashed from #2976 onto Chalk & Charcoal and Graphic Pen.
2026-10-20 02:54:06 +07:00
nrq6993-a11y 11adb72d44 G2: Effect › Sketch › Graphic Pen raster filter
Squashed from #2967 onto #2972 (Chalk & Charcoal): both filters in the effect catalog, PIXEL_EFFECTS and the Sketch label and filter tests.
2026-10-20 02:52:33 +07:00
nrq6993-a11yandCursor 52380d683d G2: Chalk & Charcoal: fix clippy on luma type
Co-authored-by: Cursor <[email protected]>
2026-10-20 02:50:13 +07:00
nrq6993-a11yandCursor 1f2007eb46 G2: Effect › Sketch › Chalk & Charcoal raster filter
Adds sketch.chalkCharcoal to the pixel-effect pipeline so Effect › Sketch exposes chalk highlights and charcoal strokes with public-doc-style parameters and tests.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:50:13 +07:00
BFlat 19e3144d27 Merge pull request #3144 from nrq6993-a11y/stream/vc-feat1-g1-envelope-distort-measure
G1: Mesh editing shows move distance in measurement labels while dragging points
2026-10-20 02:48:33 +07:00
nrq6993-a11yandCursor cfea906681 G1: Mesh point drags show dX/dY in measurement labels
Honor Measurement Labels while moving gradient-mesh and mesh-envelope
points with the Mesh tool or Direct Selection.

Co-authored-by: Cursor <[email protected]>
2026-10-20 02:48:08 +07:00
BFlat fbc654734f Merge pull request #3151 from storytold/docs/roadmap-2026-10-19
Docs: ROADMAP row for 2026-10-19, and rows doc-conflict merges duplicated
2026-10-20 02:41:11 +07:00
nrq6993-a11yandCursor 32a322531a G1: fix guide.move preview copy flag and ruler-drop Alt handling.
Align ruler-drop detection with source orientation and alt-flip readout tests with relative origins.

Co-authored-by: Cursor <[email protected]>
2026-10-19 07:51:20 +03:00
nrq6993-a11yandCursor 0d09d6a188 Docs: mark existing ruler-guide Alt orientation as done in ui-parity
Co-authored-by: Cursor <[email protected]>
2026-10-19 01:46:47 +03:00
nrq6993-a11yandCursor 73eaa84d34 G1: Alt-drag an existing ruler guide to swap its orientation
Match the public shortcut reference: Alt while dragging converts horizontal and vertical, with snapping on the new axis; guide.move accepts vertical on the index form.

Co-authored-by: Cursor <[email protected]>
2026-10-19 01:46:38 +03:00
152 changed files with 8009 additions and 706 deletions

No files matched your search

Generated
+40 -28
View File
@@ -3676,6 +3676,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"log",
"smithay-client-toolkit 0.19.2",
"tiny-skia",
]
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -4512,7 +4523,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4549,7 +4560,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4560,7 +4571,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4572,7 +4583,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4588,7 +4599,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4610,7 +4621,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4622,7 +4633,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"hayro-ccitt",
"image",
@@ -4640,7 +4651,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4656,7 +4667,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"craft-docpath",
"crc32fast",
@@ -4694,7 +4705,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"fax",
"flate2",
@@ -4717,7 +4728,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"flate2",
"proptest",
@@ -4732,7 +4743,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4744,7 +4755,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"image",
"log",
@@ -4763,7 +4774,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4777,7 +4788,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4789,7 +4800,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"aes",
"craft-pdf",
@@ -4819,7 +4830,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"serde",
"serde_json",
@@ -4833,7 +4844,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"flate2",
"gif",
@@ -4860,14 +4871,14 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"craft-docpath",
"flate2",
@@ -4892,7 +4903,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"fax",
"image",
@@ -4910,7 +4921,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4930,7 +4941,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -4938,7 +4949,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4954,7 +4965,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4966,7 +4977,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"craft-ui",
"egui",
@@ -5000,7 +5011,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"eframe",
"egui",
@@ -5948,6 +5959,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -6111,7 +6123,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.13.1"
version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+31
View File
@@ -92,3 +92,34 @@ Assets already bundled through Hayro's default features retain their licences:
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
Adobe and contributor names do not endorse or promote this product.
Per-file hashes and sources are recorded in ASSETS.md.
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
MIT License
Copyright (c) 2022 Bartłomiej Maryńczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5 -1
View File
@@ -163,6 +163,8 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
## Quick start
For posters and sewing patterns, see [Print a large poster across multiple pages](docs/print-tiling.md).
```sh
git clone https://github.com/storytold/vectorcraft
cd vectorcraft
@@ -178,6 +180,8 @@ cd apps/vectorcraft-web && trunk build --release # web build → dist
cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names
```
For text and custom letter shapes, see [Typography and Custom Lettering](docs/typography-lettering.md).
Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
@@ -246,7 +250,7 @@ The scores are self-assessed from our code against public documentation;
- 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 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;
- most of the raster effects (11 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation and Stamp 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.
+3 -3
View File
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind |
|---|---|---|
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 33/57, 3D 4/4 (initial types, no materials) |
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 46/57, 3D 4/4 (initial types, no materials) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
@@ -76,7 +76,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
| Strokes, brushes, width profiles | 88% | 8–15 |
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
| Live vector effects | 85% | 6–10 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 21–35 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 81% | 8–14 |
| 3D and Materials | ~25% | 30–55 |
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
@@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
3. **Launch and stability on real machines** (G10): 20–35 h.
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
5. **Raster effects** (G2) alongside: 24 filters and the Effect Gallery, 21–35 h.
5. **Raster effects** (G2) alongside: the 11 remaining filters (7 Artistic, 3 Sketch, 1 more) and the Effect Gallery, 8–14 h.
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
## Progress log
+3
View File
@@ -72,6 +72,9 @@ objc2-core-text = { version = "0.3", default-features = false, features = ["std"
pollster = "1"
[target.'cfg(target_os = "linux")'.dependencies]
# GNOME/Wayland needs client-side window controls without a bundled title font.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
# so Automatic draws on that one. Both are already in the build through winit.
wayland-client = "0.31"
+3
View File
@@ -137,3 +137,6 @@ sync_github_name = true
[people.MiQ71]
real_name = "Mirko Kerezovic"
sync_github_name = false
[people.KaboomFox]
sync_github_name = true
+3
View File
@@ -287,6 +287,8 @@ pub struct ArtBrush {
pub width: f64,
pub flip_along: bool,
pub flip_across: bool,
/// When true, art is placed on each run between sharp corners so folds do not overlap.
pub overlap: bool,
pub colorization: Colorization,
}
@@ -304,6 +306,7 @@ impl Default for ArtBrush {
width: 100.0,
flip_along: false,
flip_across: false,
overlap: false,
colorization: Colorization::Tints,
}
}
+22
View File
@@ -152,6 +152,28 @@ fn art_brush_between_guides_keeps_ends() {
assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}");
}
#[test]
fn art_brush_overlap_splits_at_corners() {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((100.0, 0.0));
bp.line_to((100.0, 100.0));
let brush = |overlap: bool| Brush {
name: "a".into(),
kind: BrushKind::Art(ArtBrush {
art: rect_art(10.0, 4.0),
scale: ArtScale::Stretch,
overlap,
colorization: Colorization::None,
..Default::default()
}),
};
let one = stroke_pieces(&Document::new(1.0, 1.0), &brush(false), &bp, &stroke(1.0));
let two = stroke_pieces(&Document::new(1.0, 1.0), &brush(true), &bp, &stroke(1.0));
assert_eq!(one.len(), 1, "one bent copy along the whole path");
assert_eq!(two.len(), 2, "one copy per run between corners");
}
#[test]
fn art_brush_follows_curves() {
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath();
+51 -21
View File
@@ -154,35 +154,65 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
}
/// Arc-length spans to place art on when [`ArtBrush::overlap`] is on: one run between each sharp
/// corner (the whole path when it has none).
fn art_runs(t: &Track) -> Vec<(f64, f64)> {
let l = t.len();
let corners = t.corner_positions();
if corners.is_empty() {
return vec![(0.0, l)];
}
if t.closed {
let first = corners[0].1;
let mut bounds = corners.iter().map(|c| c.1).collect::<Vec<_>>();
bounds.push(first + l);
return bounds.windows(2).map(|w| (w[0], w[1])).collect();
}
let mut bounds = vec![0.0];
bounds.extend(corners.iter().map(|c| c.1));
bounds.push(l);
bounds.windows(2).map(|w| (w[0], w[1])).collect()
}
fn art_on_run(a: &ArtBrush, t: &Track, s0: f64, s1: f64, art: &Node, b: Rect, cross: f64) -> Option<Node> {
let l = s1 - s0;
if l <= 1e-9 {
return None;
}
let w = b.width();
let (along, cross) = match a.scale {
ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross),
ArtScale::Proportional => {
let k = l / w;
(AlongMap::linear(w, s0, s1), k * a.width / 100.0)
}
ArtScale::BetweenGuides { start, end } => {
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
let (g0, g1) = (g0.min(g1), g0.max(g1));
let fixed = (g0 + (w - g1)) * cross;
if fixed >= l || g1 - g0 < 1e-9 {
(AlongMap::linear(w, s0, s1), cross)
} else {
(AlongMap { xs: [0.0, g0, g1, w], ss: [s0, s0 + g0 * cross, s1 - (w - g1) * cross, s1] }, cross)
}
}
};
bend(t, art, b, along, cross)
}
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] };
let cross = weight * a.width / 100.0;
let mut out = vec![];
for t in tracks(bp, 0.05) {
let l = t.len();
if l <= 1e-9 {
if t.len() <= 1e-9 {
continue;
}
let w = b.width();
let (along, cross) = match a.scale {
ArtScale::Stretch => (AlongMap::linear(w, 0.0, l), cross),
ArtScale::Proportional => {
let k = l / w;
(AlongMap::linear(w, 0.0, l), k * a.width / 100.0)
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, t.len())] };
for (s0, s1) in runs {
if let Some(n) = art_on_run(a, &t, s0, s1, &art, b, cross) {
out.push(n);
}
ArtScale::BetweenGuides { start, end } => {
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
let (g0, g1) = (g0.min(g1), g0.max(g1));
let fixed = (g0 + (w - g1)) * cross;
if fixed >= l || g1 - g0 < 1e-9 {
(AlongMap::linear(w, 0.0, l), cross)
} else {
(AlongMap { xs: [0.0, g0, g1, w], ss: [0.0, g0 * cross, l - (w - g1) * cross, l] }, cross)
}
}
};
if let Some(n) = bend(&t, &art, b, along, cross) {
out.push(n);
}
}
out
+1
View File
@@ -1536,6 +1536,7 @@ impl Document {
n += usize::from(*k);
}
sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect();
sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten());
let before = self.guides.len();
let mut flags = kept.into_iter();
self.guides.retain(|_| flags.next().unwrap_or(true));
+26
View File
@@ -102,6 +102,8 @@ pub enum LiveShape {
},
Polygon {
radius: f64,
/// 3–1000, as [`LiveShape::set_sides`] gives it; a file's count is clamped when read.
#[serde(deserialize_with = "de_sides")]
sides: u32,
xf: Affine,
/// Corner radii in document units, by vertex from the first one clockwise (none: sharp).
@@ -326,6 +328,12 @@ fn all_round(kinds: &[CornerKind]) -> bool {
kinds.iter().all(|k| *k == CornerKind::Round)
}
/// A polygon's side count read from a file, clamped as the commands clamp it: the outline and its
/// corner lists hold one entry per side, so a huge count would allocate gigabytes.
fn de_sides<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
u32::deserialize(d).map(|n| n.clamp(3, 1000))
}
/// Whether `a` only moves (no rotation, scale, shear or reflection).
fn moves_only(a: Affine) -> bool {
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
@@ -2190,6 +2198,24 @@ mod tests {
assert_eq!(path.label(), "Path");
}
/// A polygon read from a file has 3 to 1000 sides, as the commands give it: selecting one
/// with four billion made its outline and corner lists allocate tens of gigabytes.
#[test]
fn a_polygon_from_a_file_has_3_to_1000_sides() {
let read = |sides: u32| {
let json = serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": sides, "xf": [1, 0, 0, 1, 0, 0]});
serde_json::from_value::<LiveShape>(json).unwrap()
};
let sides_of = |l: &LiveShape| match l {
LiveShape::Polygon { sides, .. } => *sides,
_ => panic!("not a polygon: {l:?}"),
};
let huge = read(4_000_000_000);
assert_eq!([0, 5, 1000].map(|n| sides_of(&read(n))), [3, 5, 1000]);
assert_eq!(sides_of(&huge), 1000);
assert_eq!(huge.polygon_outline().anchor_count(), 1000);
}
#[test]
fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
+22 -4
View File
@@ -43,6 +43,11 @@ pub struct Selection {
/// guides and objects can be selected together, in either order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub guides: Vec<usize>,
/// A selected ruler guide that is the key object for Align instead of [`Self::key`] (an index
/// into [`Document::guides`], one of [`Self::guides`]): the objects line up on it. Never set
/// with `key`; [`Self::prune`] clears it when no object is selected to align to it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_guide: Option<usize>,
}
impl Selection {
@@ -67,6 +72,11 @@ impl Selection {
self.target = None;
self.slices.clear();
self.guides.clear();
self.key_guide = None;
}
/// Is there a key for Align: an object or a guide?
pub fn has_key(&self) -> bool {
self.key.is_some() || self.key_guide.is_some()
}
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.clear();
@@ -114,6 +124,9 @@ impl Selection {
pub fn toggle_guide(&mut self, i: usize) {
if let Some(k) = self.guides.iter().position(|g| *g == i) {
self.guides.remove(k);
if self.key_guide == Some(i) {
self.key_guide = None;
}
} else {
self.guides.push(i);
}
@@ -162,13 +175,16 @@ impl Selection {
refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count()));
!refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id)
});
// The key object stays while another selected object lies outside it.
// Guides on a hidden or locked layer can't stay selected.
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
// The key object stays while another selected object lies outside it, or while ruler
// guides are selected for it to line up (#2143).
let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| {
let mut inside = BTreeSet::new();
n.walk(&mut |c| {
inside.insert(c.id);
});
self.objects.iter().any(|o| !inside.contains(o))
self.objects.iter().any(|o| !inside.contains(o)) || (self.objects.contains(&n.id) && !self.guides.is_empty())
});
if !keep {
self.key = None;
@@ -177,8 +193,10 @@ impl Selection {
self.target = None;
}
self.slices.retain(|id| doc.is_slice(*id));
// Guides on a hidden or locked layer can't stay selected.
self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g)));
// A key guide needs objects to line up on it, and stays selected.
if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) {
self.key_guide = None;
}
}
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
/// around them (and so the key object when it is one): hidden or locked art can't stay
+116 -7
View File
@@ -56,7 +56,7 @@ mod tests;
mod tests_scale;
use serde_json::{Map, Value, json};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use adjust::{
@@ -153,7 +153,9 @@ fn lengths_of(id: &str) -> Lengths {
"texture.mosaicTiles" => always(&["tileSize"]),
"texture.patchwork" => always(&["squareSize"]),
"texture.stainedGlass" => always(&["cellSize"]),
"sketch.graphicPen" => always(&["strokeLength"]),
"sketch.halftonePattern" => always(&["size"]),
"sketch.notePaper" => always(&["relief"]),
RASTERIZE => always(&["addAround"]),
_ => Lengths::default(),
}
@@ -362,6 +364,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}),
),
r(
"artistic.plasticWrap",
"Plastic Wrap…",
ARTISTIC,
"{highlightStrength: 0..20 (20; brighter specular highlights), detail: 0..15 (12; finer wrinkles in document space), smoothness: 1..15 (15; softer folds)} coats the object in shiny plastic, keeping its coverage. highlightStrength 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
json!({"highlightStrength": 20.0, "detail": 12.0, "smoothness": 15.0}),
),
r(
"artistic.posterEdges",
"Poster Edges…",
@@ -369,6 +378,27 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}),
),
r(
"artistic.smudgeStick",
"Smudge Stick…",
ARTISTIC,
"{strokeLength: 0..10 (2; length of the short diagonal strokes), highlightArea: 0..20 (12; widens the area counted as light), intensity: 0..10 (10; how far the light areas are lightened)} smudges the dark areas along short diagonal strokes; the lighter areas become brighter and lose detail. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"strokeLength": 2.0, "highlightArea": 12.0, "intensity": 10.0}),
),
r(
"artistic.sponge",
"Sponge…",
ARTISTIC,
"{brushSize: 0..10 (2; size of the sponge's pores), definition: 0..25 (12; how strongly lighter and darker blobs contrast), smoothness: 1..15 (5; softer blob edges)} paints the object as if dabbed with a sponge, in highly textured areas of contrasting colour. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"brushSize": 2.0, "definition": 12.0, "smoothness": 5.0}),
),
r(
"artistic.watercolor",
"Watercolor…",
ARTISTIC,
"{brushDetail: 1..14 (9; the larger, the more detail stays), shadowIntensity: 0..10 (1; deepens the shadows), texture: 1..3 (1; paper grain, whole numbers)} paints the object in a watercolor style: detail simplified, colour saturated and darker where tones change at edges. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"brushDetail": 9.0, "shadowIntensity": 1.0, "texture": 1.0}),
),
r(
"blur.radial",
"Radial Blur…",
@@ -467,13 +497,83 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
),
r(
"sketch.basRelief",
"Bas Relief…",
SKETCH,
"{detail: 1..15 (13; the larger, the finer the features that stay), smoothness: 1..15 (3; rounds the carving), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"bottom\"; where the light comes from)} carves the object in low relief in black and white: surfaces facing the light turn white, those turned away black, flat areas grey. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"}),
),
r(
"sketch.chalkCharcoal",
"Chalk & Charcoal…",
SKETCH,
"{chalkArea: 0..20 (6; chalk in light areas), charcoalArea: 0..20 (6; charcoal in dark areas), strokePressure: 0..5 (2)} redraws the object with chalk highlights and charcoal strokes on white paper, keeping its coverage. Original visual approximation from public documentation; slider ranges and defaults are VectorCraft choices, not verified proprietary values.",
json!({"chalkArea": 6.0, "charcoalArea": 6.0, "strokePressure": 2.0}),
),
r(
"sketch.charcoal",
"Charcoal…",
SKETCH,
"{thickness: 1..7 (1; width of the bold edges and of the strokes' spacing), detail: 0..5 (1; the larger, the finer the edges), lightDarkBalance: 0..100 (50; how far into the lights the charcoal reaches)} redraws the object in black charcoal on white paper: bold edges and diagonal strokes over the midtones. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"thickness": 1.0, "detail": 1.0, "lightDarkBalance": 50.0}),
),
r(
"sketch.chrome",
"Chrome…",
SKETCH,
"{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"detail": 4.0, "smoothness": 4.0}),
),
r(
"sketch.graphicPen",
"Graphic Pen…",
SKETCH,
"{strokeLength: pt 1..15 (5; the longest ink stroke), lightDarkBalance: 0..100 (50; more black when higher), strokeDirection: \"rightDiagonal\"|\"horizontal\"|\"leftDiagonal\"|\"vertical\" (\"rightDiagonal\")} redraws the object in fine linear black ink on white paper, with strokes denser in darker areas. Control names and ranges follow the public documentation; the defaults are unverified.",
json!({"strokeLength": 5.0, "lightDarkBalance": 50.0, "strokeDirection": "rightDiagonal"}),
),
r(
"sketch.halftonePattern",
"Halftone Pattern…",
SKETCH,
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
"{size: pt 1..12 (1; how far apart the screen's dots, rings or lines are), contrast: 0..50 (5; 50 draws pure black and white), patternType: \"circle\"|\"dot\"|\"line\" (\"dot\"; rings round the object's centre, dots on a square grid, horizontal lines)} redraws the object in black ink on white paper through a halftone screen whose ink covers as much as the colour is dark, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).",
json!({"size": 1.0, "contrast": 5.0, "patternType": "dot"}),
),
r(
"sketch.notePaper",
"Note Paper…",
SKETCH,
"{imageBalance: 0..50 (25; lower tones darken the emboss, higher lighten it), graininess: 0..10 (0; fibrous paper texture), relief: 0..50 (4; emboss strength in points)} embosses the object's tones on warm paper, keeping its coverage. Original visual approximation from the public Sketch effects reference; slider ranges follow the documented control names, numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults (the public help pages give no numbers).",
json!({"imageBalance": 25.0, "graininess": 0.0, "relief": 4.0}),
),
r(
"sketch.photocopy",
"Photocopy…",
SKETCH,
"{detail: 1..24 (7; the larger, the finer the features that copy), darkness: 1..50 (25; the larger, the more of the dark copies)} copies the object in black on white: large dark areas copy only around their edges and midtones go to black or white. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"detail": 7.0, "darkness": 25.0}),
),
r(
"sketch.plaster",
"Plaster…",
SKETCH,
"{imageBalance: 1..50 (20; higher values sink more of the image), smoothness: 1..15 (8; higher values soften the relief), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\")} molds the object in relief plaster and lights it from that direction; dark areas are raised and light areas recessed, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"imageBalance": 20.0, "smoothness": 8.0, "lightDirection": "top"}),
),
r(
"sketch.reticulation",
"Reticulation…",
SKETCH,
"{density: 0..50 (12; finer grain at higher values), foregroundLevel: 0..50 (6; pulls shadows toward black ink), backgroundLevel: 0..50 (6; pulls highlights toward white paper)} redraws the object in black ink on white paper through a high-contrast film-grain screen, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"density": 12.0, "foregroundLevel": 6.0, "backgroundLevel": 6.0}),
),
r(
"sketch.stamp",
"Stamp…",
SKETCH,
"{lightDarkBalance: 0..50 (25; the larger, the more of the object prints black), smoothness: 1..50 (5; rounds the shapes)} simplifies the object to solid black and white, as a rubber stamp would print it. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.",
json!({"lightDarkBalance": 25.0, "smoothness": 5.0}),
),
r(
"stylize.glowingEdges",
"Glowing Edges…",
@@ -841,6 +941,12 @@ pub struct GeomContext<'a> {
/// The seed of the Transform effect's Random: the object's id, so each object varies its own
/// way and keeps its result on every redraw.
pub seed: u64,
/// The object's appearance, which live-effect plug-ins are given read only (their fills and
/// strokes).
pub appearance: Option<&'a Appearance>,
/// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`:
/// the object's effects.
pub item: Option<usize>,
}
impl<'a> GeomContext<'a> {
@@ -850,15 +956,17 @@ impl<'a> GeomContext<'a> {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
_ => FillRule::NonZero,
};
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule, seed: n.id.0 }
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
/// stroke's own effects outline that stroke.
/// stroke's own effects outline that stroke, and a plug-in is told which item it is on.
pub fn item(self, item: &'a AppearanceItem) -> Self {
let at = self.appearance.and_then(|a| a.items.iter().position(|i| std::ptr::eq(i, item)));
match item {
AppearanceItem::Stroke(s) => Self { stroke: Some(s), ..self },
AppearanceItem::Fill(_) => self,
AppearanceItem::Stroke(s) => Self { stroke: Some(s), item: at, ..self },
AppearanceItem::Fill(_) => Self { item: at, ..self },
}
}
}
@@ -897,7 +1005,8 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
use util::*;
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone());
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
}
match id {
"distort.freeDistort" => distort::free_distort(path, b, p),
+76 -1
View File
@@ -1,4 +1,4 @@
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Poster Edges). Original filters built from
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges). Original filters built from
//! public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html>.
@@ -6,6 +6,8 @@
use super::edges::sobel;
use super::pixelate::{Place, cell, rand};
const SALT_PLASTIC: u64 = 512;
use super::texture::{luma, premultiply, straight};
use super::{PixelSpace, blur_plane};
use vectorcraft_geom::Point;
@@ -239,6 +241,79 @@ pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
}
}
/// How far (document units) Plastic Wrap's softened relief is sampled at `smoothness` (1..15).
pub(super) fn plastic_reach(smoothness: f64) -> f64 {
3.0 * (0.15 + 0.35 * smoothness.clamp(1.0, 15.0))
}
/// Plastic Wrap: the object under shiny plastic. Its tones form a relief; `detail` (0..15) adds
/// finer wrinkles in document space; `smoothness` (1..15) softens the relief; specular highlights
/// as strong as `highlight` (0..20) are screened over it. Coverage is kept.
pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, highlight: f64, detail: f64, smoothness: f64) {
if ![highlight, detail, smoothness].iter().all(|v| v.is_finite()) || !space.px.is_finite() || space.px <= 0.0 {
return;
}
let strength = (highlight / 20.0).clamp(0.0, 1.0) as f32;
if strength == 0.0 {
return;
}
let detail_amt = (detail / 15.0).clamp(0.0, 1.0) as f32;
let sigma = (0.15 + 0.35 * smoothness.clamp(1.0, 15.0)) / space.px;
let Some(place) = Place::new(space, w, h) else { return };
let len = px.len();
let mut height = vec![0.0f32; len];
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let cell_scale = 8.0 + 24.0 * (1.0 - detail_amt as f64);
let mut noise = CellNoise::default();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let i = y * w + x;
let l = luma(straight(*p));
let q = place.at(x, y);
let nq = Point::new(q.x / cell_scale, q.y / cell_scale);
height[i] = l + detail_amt * 0.35 * noise.at(SALT_PLASTIC, nq) as f32;
});
for (h, a) in height.iter_mut().zip(&alpha) {
*h *= *a;
}
blur_plane(&mut height, w, h, sigma);
let mut cov = alpha.clone();
blur_plane(&mut cov, w, h, sigma);
for (h, c) in height.iter_mut().zip(&cov) {
*h = if *c > 1e-4 { *h / *c } else { 0.0 };
}
const LIGHT: [f32; 3] = [0.35, -0.25, 0.9];
let inv = (LIGHT[0] * LIGHT[0] + LIGHT[1] * LIGHT[1] + LIGHT[2] * LIGHT[2]).sqrt().max(1e-6);
let light = LIGHT.map(|v| v / inv);
let scale = (8.0 * space.px).max(1e-6) as f32;
let sample = |x: isize, y: isize| -> f32 {
if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
return 0.0;
}
height[y as usize * w + x as usize]
};
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let gx = sample(x as isize + 1, y as isize) - sample(x as isize - 1, y as isize);
let gy = sample(x as isize, y as isize + 1) - sample(x as isize, y as isize - 1);
let nx = -gx / scale;
let ny = -gy / scale;
let nz = 1.0;
let nlen = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
let ndotl = (nx / nlen * light[0] + ny / nlen * light[1] + nz / nlen * light[2]).max(0.0);
let spec = ndotl.powi(12) * strength;
if spec <= 1e-5 {
return;
}
let c = straight(*p).map(|v| 1.0 - (1.0 - v) * (1.0 - spec));
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// How far (document units) Neon Glow's glow spreads at `size` (-24..24).
pub(super) fn glow_spread(size: f64) -> f64 {
0.25 * size.abs().min(24.0)
+51 -14
View File
@@ -64,9 +64,9 @@ impl StrokeDirection {
}
/// Along a right diagonal stroke, rising to the right (y down).
const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
pub(super) const RIGHT: Vec2 = Vec2::new(FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
/// Along a left diagonal stroke, falling to the right.
const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2);
pub(super) const LEFT: Vec2 = Vec2::new(FRAC_1_SQRT_2, FRAC_1_SQRT_2);
/// The most samples a stroke averages, whatever its length.
const MAX_SAMPLES: usize = 16;
@@ -75,7 +75,7 @@ const MAX_SAMPLES: usize = 16;
const JITTER: f64 = 0.25;
/// How soft (points, the σ) the tones are that decide which strokes a pixel gets.
const TONE_SOFTNESS: f64 = 1.0;
pub(super) const TONE_SOFTNESS: f64 = 1.0;
/// Dark Strokes' short dark strokes and long light ones (points).
pub(super) const DARK_LENGTH: f64 = 3.0;
@@ -86,7 +86,7 @@ const INK_SOFTNESS: f64 = 0.6;
/// A pixel at 72 ppi (points): how far the neighbours of the Sobel operator and of sampling
/// between pixels add to a filter's reach.
const MARGIN: f64 = 1.0;
pub(super) const MARGIN: f64 = 1.0;
/// How far (document units) strokes `len` long reach: half their length, their random shift, the
/// softening between their samples and the pixels a sample is taken between.
@@ -137,18 +137,18 @@ pub(super) fn spray_shift(radius: f64) -> f64 {
/// The object's colours averaged along strokes `len` long: [`MAX_SAMPLES`] at most, the object
/// softened first when they lie more than a pixel apart.
struct Smear<'a> {
pub(super) struct Smear<'a> {
/// Premultiplied colours (0..1).
src: Vec<[f32; 4]>,
w: usize,
h: usize,
place: &'a Place,
len: f64,
pub(super) len: f64,
n: usize,
}
impl<'a> Smear<'a> {
fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self {
pub(super) fn new(px: &[[u8; 4]], w: usize, h: usize, place: &'a Place, len: f64) -> Self {
let len = if len.is_finite() { len.max(0.0) } else { 0.0 };
let span = len / place.px;
let n = if span.is_finite() { (span.ceil() as usize).saturating_add(1).clamp(1, MAX_SAMPLES) } else { MAX_SAMPLES };
@@ -218,7 +218,7 @@ impl<'a> Smear<'a> {
}
/// The stroke of pattern `salt` through `q` along `dir`, its rows `width` apart.
fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] {
pub(super) fn stroke(&self, salt: u64, q: Point, dir: Vec2, width: f64) -> [f32; 4] {
self.at(q + dir * stroke_shift(salt, q, dir, self.len, width), dir)
}
}
@@ -242,37 +242,37 @@ fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 {
}
/// Smooth noise in [-1, 1) streaked along `dir`: blobs `along` long and `across` wide.
fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 {
pub(super) fn streak(salt: u64, q: Point, dir: Vec2, along: f64, across: f64) -> f64 {
let (u, v) = frame(q, dir);
2.0 * value_noise(salt, u / along.max(1e-3), v / across.max(1e-3)) - 1.0
}
/// The straight colour and coverage of premultiplied `pm` (0..1).
fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) {
pub(super) fn unpremultiply(pm: [f32; 4]) -> ([f32; 3], f32) {
let a = pm[3].clamp(0.0, 1.0);
if a > 1e-6 { ([pm[0], pm[1], pm[2]].map(|v| (v / a).clamp(0.0, 1.0)), a) } else { ([0.0; 3], 0.0) }
}
/// `a` + (`b` − `a`) × `t`, channel by channel.
fn mix<const N: usize>(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] {
pub(super) fn mix<const N: usize>(a: [f32; N], b: [f32; N], t: f32) -> [f32; N] {
std::array::from_fn(|k| a[k] + (b[k] - a[k]) * t)
}
/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1.
fn ramp(t: f64) -> f64 {
pub(super) fn ramp(t: f64) -> f64 {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
t * t * (3.0 - 2.0 * t)
}
/// Pixel `p` (premultiplied 0..255) as premultiplied 0..1.
fn unit(p: [u8; 4]) -> [f32; 4] {
pub(super) fn unit(p: [u8; 4]) -> [f32; 4] {
p.map(|v| f32::from(v) / 255.0)
}
/// The object's edges after softening it by `sigma` (document units): about 1 across a step from
/// black to white or from clear to opaque, the same at any resolution. Empty when the sizes don't
/// match.
fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
pub(super) fn edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let s = sigma / place.px;
@@ -516,3 +516,40 @@ pub(super) fn sumi_e(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
*p = premultiply(out, f32::from(p[3]) / 255.0);
});
}
const SALT_GRAPHIC: u64 = 200;
/// Graphic Pen: the object redrawn in fine linear ink strokes along `dir`, each `len` (1..15)
/// points long on white paper; `balance` (0..100) sets the tone threshold (higher draws more
/// black ink).
pub(super) fn graphic_pen(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, len: f64, balance: f64, dir: StrokeDirection) {
let Some(place) = Place::new(space, w, h) else { return };
let len = if len.is_finite() { len.clamp(1.0, 15.0) } else { 1.0 };
let smear = Smear::new(px, w, h, &place, len);
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
let threshold = 1.0 - balance.clamp(0.0, 100.0) / 100.0;
let row = (0.2 * len).max(0.75);
let along = dir.along();
place.each(px, |x, y, p| {
let a = f32::from(p[3]) / 255.0;
if a <= 0.0 {
return;
}
let q = place.at(x, y);
let (c, stroke_a) = unpremultiply(smear.stroke(SALT_GRAPHIC, q, along, row));
let dark = (1.0 - tones.get(x, y)).max(f64::from(1.0 - luma(c)) * f64::from(stroke_a));
if dark <= threshold {
*p = premultiply([1.0, 1.0, 1.0], a);
return;
}
let weight = ramp((dark - threshold) / (1.0 - threshold).max(1e-3));
let u = frame(q, along).0;
let on_stroke = {
let t = (u / len).rem_euclid(1.0);
if t < 0.65 { 1.0 } else { 0.0 }
};
let grain = 0.08 * streak(SALT_GRAPHIC + 1, q, along, 0.35 * len, 0.5);
let ink = (weight * on_stroke + grain).clamp(0.0, 1.0);
*p = premultiply([(1.0 - ink) as f32; 3], a);
});
}
+178 -9
View File
@@ -1,7 +1,9 @@
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow and Poster Edges, Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes
//! and Sumi-e, Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone,
//! Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Stylize ›
//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow, Plastic Wrap and
//! Poster Edges, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
//! Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes and Sumi-e, Distort › Diffuse
//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize,
//! Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Plaster and
//! Reticulation, Stylize ›
//! Glowing Edges, Texture
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
@@ -19,9 +21,11 @@ mod brushstrokes;
mod distort;
mod edges;
mod loaded;
mod paint;
mod pixelate;
mod sharpen;
mod sketch;
mod sketch_ink;
mod texture;
mod video;
@@ -39,11 +43,15 @@ pub use sketch::{HALFTONE_TYPES, HalftonePattern};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 32] = [
pub const PIXEL_EFFECTS: [&str; 46] = [
"artistic.cutout",
"artistic.filmGrain",
"artistic.neonGlow",
"artistic.plasticWrap",
"artistic.posterEdges",
"artistic.smudgeStick",
"artistic.sponge",
"artistic.watercolor",
"blur.radial",
"blur.smart",
"brushStrokes.accentedEdges",
@@ -62,7 +70,17 @@ pub const PIXEL_EFFECTS: [&str; 32] = [
"pixelate.mezzotint",
"pixelate.pointillize",
"sharpen.unsharpMask",
"sketch.basRelief",
"sketch.chalkCharcoal",
"sketch.charcoal",
"sketch.chrome",
"sketch.graphicPen",
"sketch.halftonePattern",
"sketch.notePaper",
"sketch.photocopy",
"sketch.plaster",
"sketch.reticulation",
"sketch.stamp",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
@@ -84,10 +102,23 @@ pub enum PixelFx {
/// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright
/// highlight area (0..20), and tonal intensity (0..10), preserving coverage.
FilmGrain { grain: f64, highlight: f64, intensity: f64 },
/// Artistic › Smudge Stick: the dark areas smudged along short diagonal strokes `stroke`
/// (0..10) long, the light areas (widened by `highlight`, 0..20) smudged longer and lightened
/// by up to `intensity` (0..10).
SmudgeStick { stroke: f64, highlight: f64, intensity: f64 },
/// Artistic › Sponge: soft blobs of lighter and darker colour about `brush` (0..10) large,
/// contrasting as `definition` (0..25) says, their edges as soft as `smoothness` (1..15).
Sponge { brush: f64, definition: f64, smoothness: f64 },
/// Artistic › Watercolor: detail simplified by a brush of `detail` (1..14), edges saturated,
/// shadows deepened by `shadow` (0..10), `texture` (1..3) grains of paper showing.
Watercolor { detail: f64, shadow: f64, texture: u32 },
/// Artistic › Neon Glow: dimmed greys lit by `colour` (straight RGB, 0..1) glowing from the
/// light areas (`size` above 0) or the dark ones (below 0), spread by |`size`| (-24..24), as
/// strong as `brightness` (0..50).
NeonGlow { size: f64, brightness: f64, colour: [f32; 3] },
/// Artistic › Plastic Wrap: specular highlights as strong as `highlight` (0..20) on a relief
/// wrinkled by `detail` (0..15) and softened by `smoothness` (1..15).
PlasticWrap { highlight: f64, detail: f64, smoothness: f64 },
/// Artistic › Poster Edges: colours posterized to `posterization` + 2 levels (0..6) with black
/// lines on the edges, as thick as `thickness` (0..10) and as dark as `intensity` (0..10).
PosterEdges { thickness: f64, intensity: f64, posterization: f64 },
@@ -139,9 +170,38 @@ pub enum PixelFx {
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
/// Sketch › Chalk & Charcoal: chalk highlights in light areas and charcoal in dark ones;
/// `chalk` and `charcoal` (0..20) set how much, `pressure` (0..5) the stroke weight.
ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 },
/// Sketch › Chrome: polished chrome relief with `detail` (0..10) and `smoothness` (0..10).
Chrome { detail: f64, smoothness: f64 },
/// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long
/// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink.
GraphicPen { length: f64, balance: f64, direction: StrokeDirection },
/// Sketch › Halftone Pattern: black ink on white paper through a `kind` screen of cells `size`
/// (1..12) apart, its edges as hard as `contrast` (0..50) says.
HalftonePattern { size: f64, contrast: f64, kind: HalftonePattern },
/// Sketch › Note Paper: embossed tones on fibrous paper (`balance`, `graininess`, `relief`).
NotePaper { balance: f64, graininess: f64, relief: f64 },
/// Sketch › Bas Relief: low relief lit from `light`, keeping the finer features as `detail`
/// (1..15) says and rounded by `smoothness` (1..15).
BasRelief { detail: f64, smoothness: f64, light: Light },
/// Sketch › Charcoal: bold edges `thickness` (1..7) wide, as fine as `detail` (0..5) says, and
/// diagonal strokes over the midtones; `balance` (0..100) sets how far into the lights it reaches.
Charcoal { thickness: f64, detail: f64, balance: f64 },
/// Sketch › Photocopy: black only where a pixel is darker than its surroundings, as fine as
/// `detail` (1..24) says, and as readily as `darkness` (1..50) says.
Photocopy { detail: f64, darkness: f64 },
/// Sketch › Stamp: solid black and white, printing black as much as `balance` (0..50) says,
/// its shapes rounded by `smoothness` (1..50).
Stamp { balance: f64, smoothness: f64 },
/// Sketch › Plaster: relief plaster lit from `light`; dark areas raised, light recessed;
/// `imageBalance` (1..50) shifts more of the image lower at higher values; `smoothness` (1..15)
/// softens the relief.
Plaster { image_balance: f64, smoothness: f64, light: Light },
/// Sketch › Reticulation: black ink on white paper through a high-contrast film-grain screen;
/// `density` (0..50), `foreground` and `background` (0..50) pull tones before the grain.
Reticulation { density: f64, foreground: f64, background: f64 },
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
@@ -242,6 +302,26 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
},
"artistic.smudgeStick" => PixelFx::SmudgeStick {
stroke: num(p, "strokeLength", 2.0).clamp(0.0, 10.0),
highlight: num(p, "highlightArea", 12.0).clamp(0.0, 20.0),
intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0),
},
"artistic.sponge" => PixelFx::Sponge {
brush: num(p, "brushSize", 2.0).clamp(0.0, 10.0),
definition: num(p, "definition", 12.0).clamp(0.0, 25.0),
smoothness: num(p, "smoothness", 5.0).clamp(1.0, 15.0),
},
"artistic.watercolor" => PixelFx::Watercolor {
detail: num(p, "brushDetail", 9.0).clamp(1.0, 14.0),
shadow: num(p, "shadowIntensity", 1.0).clamp(0.0, 10.0),
texture: num(p, "texture", 1.0).clamp(1.0, 3.0).round() as u32,
},
"artistic.plasticWrap" => PixelFx::PlasticWrap {
highlight: num(p, "highlightStrength", 20.0).clamp(0.0, 20.0),
detail: num(p, "detail", 12.0).clamp(0.0, 15.0),
smoothness: num(p, "smoothness", 15.0).clamp(1.0, 15.0),
},
"blur.radial" => PixelFx::RadialBlur {
amount: num(p, "amount", 10.0).clamp(1.0, 100.0),
zoom: text(p, "method", "spin").eq_ignore_ascii_case("zoom"),
@@ -311,11 +391,53 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
},
"sketch.chalkCharcoal" => PixelFx::ChalkCharcoal {
chalk: num(p, "chalkArea", 6.0).clamp(0.0, 20.0),
charcoal: num(p, "charcoalArea", 6.0).clamp(0.0, 20.0),
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0),
},
"sketch.chrome" => PixelFx::Chrome { detail: num(p, "detail", 4.0).clamp(0.0, 10.0), smoothness: num(p, "smoothness", 4.0).clamp(0.0, 10.0) },
"sketch.graphicPen" => PixelFx::GraphicPen {
length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0),
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
direction: StrokeDirection::parse(text(p, "strokeDirection", "rightDiagonal")),
},
"sketch.halftonePattern" => PixelFx::HalftonePattern {
size: num(p, "size", 1.0).clamp(1.0, 12.0),
contrast: num(p, "contrast", 5.0).clamp(0.0, 50.0),
kind: HalftonePattern::parse(text(p, "patternType", "dot")),
},
"sketch.notePaper" => PixelFx::NotePaper {
balance: num(p, "imageBalance", 25.0).clamp(0.0, 50.0),
graininess: num(p, "graininess", 0.0).clamp(0.0, 10.0),
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
},
"sketch.basRelief" => PixelFx::BasRelief {
detail: num(p, "detail", 13.0).clamp(1.0, 15.0),
smoothness: num(p, "smoothness", 3.0).clamp(1.0, 15.0),
light: Light::parse(text(p, "lightDirection", "bottom")),
},
"sketch.charcoal" => PixelFx::Charcoal {
thickness: num(p, "thickness", 1.0).clamp(1.0, 7.0),
detail: num(p, "detail", 1.0).clamp(0.0, 5.0),
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
},
"sketch.photocopy" => {
PixelFx::Photocopy { detail: num(p, "detail", 7.0).clamp(1.0, 24.0), darkness: num(p, "darkness", 25.0).clamp(1.0, 50.0) }
}
"sketch.stamp" => {
PixelFx::Stamp { balance: num(p, "lightDarkBalance", 25.0).clamp(0.0, 50.0), smoothness: num(p, "smoothness", 5.0).clamp(1.0, 50.0) }
}
"sketch.plaster" => PixelFx::Plaster {
image_balance: num(p, "imageBalance", 20.0).clamp(1.0, 50.0),
smoothness: num(p, "smoothness", 8.0).clamp(1.0, 15.0),
light: Light::parse(text(p, "lightDirection", "top")),
},
"sketch.reticulation" => PixelFx::Reticulation {
density: num(p, "density", 12.0).clamp(0.0, 50.0),
foreground: num(p, "foregroundLevel", 6.0).clamp(0.0, 50.0),
background: num(p, "backgroundLevel", 6.0).clamp(0.0, 50.0),
},
"stylize.glowingEdges" => PixelFx::GlowingEdges {
width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0),
brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0),
@@ -381,7 +503,9 @@ impl PixelFx {
PixelFx::SmartBlur { radius, .. } => radius,
PixelFx::AccentedEdges { .. } => 0.0,
// Strokes reach out of the object as far as they reach into it.
PixelFx::AngledStrokes { length, .. } | PixelFx::InkOutlines { length, .. } => brushstrokes::stroke_reach(length),
PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } | PixelFx::InkOutlines { length, .. } => {
brushstrokes::stroke_reach(length)
}
PixelFx::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length),
PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH),
PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius),
@@ -392,11 +516,21 @@ impl PixelFx {
// Content shifted outwards reaches past the bounds by as much as the shift.
PixelFx::Glass { distortion, .. } => distort::glass_shift(distortion),
PixelFx::OceanRipple { size, magnitude } => distort::ripple_shift(size, magnitude),
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
PixelFx::UnsharpMask { .. }
| PixelFx::ChalkCharcoal { .. }
| PixelFx::Chrome { .. }
| PixelFx::HalftonePattern { .. }
| PixelFx::NotePaper { .. }
| PixelFx::Plaster { .. }
| PixelFx::Reticulation { .. } => 0.0,
PixelFx::BasRelief { .. } | PixelFx::Charcoal { .. } | PixelFx::Photocopy { .. } | PixelFx::Stamp { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
// These three keep the object's coverage.
PixelFx::SmudgeStick { .. } | PixelFx::Sponge { .. } | PixelFx::Watercolor { .. } => 0.0,
PixelFx::FilmGrain { .. }
| PixelFx::Cutout { .. }
| PixelFx::NeonGlow { .. }
| PixelFx::PlasticWrap { .. }
| PixelFx::PosterEdges { .. }
| PixelFx::ColorHalftone { .. }
| PixelFx::Mezzotint { .. }
@@ -420,9 +554,14 @@ impl PixelFx {
PixelFx::RadialBlur { .. } => None,
PixelFx::SmartBlur { radius, mode: SmartBlurMode::Normal, .. } => Some(radius),
PixelFx::SmartBlur { radius, samples, .. } => Some(blur::edge_reach(radius, samples)),
PixelFx::SmudgeStick { stroke, .. } => Some(paint::smudge_reach(stroke)),
PixelFx::Sponge { brush, smoothness, .. } => Some(paint::sponge_reach(brush, smoothness)),
PixelFx::Watercolor { detail, .. } => Some(paint::watercolor_reach(detail)),
PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)),
// The strokes, and the softened tones that choose them.
PixelFx::AngledStrokes { length, .. } => Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)),
PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } => {
Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH))
}
PixelFx::Crosshatch { length, .. } => {
Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH))
}
@@ -435,15 +574,27 @@ impl PixelFx {
PixelFx::Glass { distortion, .. } => Some(distort::glass_shift(distortion)),
PixelFx::OceanRipple { size, magnitude } => Some(distort::ripple_shift(size, magnitude)),
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
PixelFx::BasRelief { detail, smoothness, .. } => Some(sketch_ink::relief_reach(detail, smoothness)),
PixelFx::Charcoal { thickness, detail, .. } => Some(sketch_ink::charcoal_reach(thickness, detail)),
PixelFx::Photocopy { detail, .. } => Some(sketch_ink::copy_reach(detail)),
PixelFx::Stamp { smoothness, .. } => Some(sketch_ink::stamp_reach(smoothness)),
PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)),
// The cells around the pixel's, and the cells around theirs.
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
PixelFx::Crystallize { cell } => Some(1.5 * cell),
PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } => Some(0.0),
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)),
PixelFx::FilmGrain { .. }
| PixelFx::Mezzotint { .. }
| PixelFx::HalftonePattern { .. }
| PixelFx::NotePaper { .. }
| PixelFx::Reticulation { .. } => Some(0.0),
PixelFx::Plaster { smoothness, .. } => Some(sketch::plaster_reach(smoothness)),
// Cutout's palette comes from the whole object.
PixelFx::Cutout { .. } => None,
// The softened tones around the pixel.
PixelFx::NeonGlow { size, .. } => Some(3.0 * artistic::glow_spread(size)),
PixelFx::PlasticWrap { smoothness, .. } => Some(artistic::plastic_reach(smoothness)),
// The edges' lines, spread round them.
PixelFx::PosterEdges { thickness, .. } => Some(3.0 * (0.3 + 0.25 * thickness) + 1.0),
// The dots' points, and the softened colour around them.
@@ -468,6 +619,10 @@ impl PixelFx {
match *self {
PixelFx::Cutout { levels, simplicity, fidelity } => artistic::cutout(px, w, h, space, levels, simplicity, fidelity),
PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity),
PixelFx::SmudgeStick { stroke, highlight, intensity } => paint::smudge_stick(px, w, h, space, stroke, highlight, intensity),
PixelFx::Sponge { brush, definition, smoothness } => paint::sponge(px, w, h, space, brush, definition, smoothness),
PixelFx::Watercolor { detail, shadow, texture } => paint::watercolor(px, w, h, space, detail, shadow, texture),
PixelFx::PlasticWrap { highlight, detail, smoothness } => artistic::plastic_wrap(px, w, h, space, highlight, detail, smoothness),
PixelFx::NeonGlow { size, brightness, colour } => artistic::neon_glow(px, w, h, space, size, brightness, colour),
PixelFx::PosterEdges { thickness, intensity, posterization } => {
artistic::poster_edges(px, w, h, space, thickness, intensity, posterization)
@@ -493,7 +648,17 @@ impl PixelFx {
}
PixelFx::OceanRipple { size, magnitude } => distort::ocean_ripple(px, w, h, space, size, magnitude),
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness),
PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction),
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
PixelFx::NotePaper { balance, graininess, relief } => sketch::note_paper(px, w, h, space, balance, graininess, relief),
PixelFx::BasRelief { detail, smoothness, light } => sketch_ink::bas_relief(px, w, h, space, detail, smoothness, light),
PixelFx::Charcoal { thickness, detail, balance } => sketch_ink::charcoal(px, w, h, space, thickness, detail, balance),
PixelFx::Photocopy { detail, darkness } => sketch_ink::photocopy(px, w, h, space, detail, darkness),
PixelFx::Stamp { balance, smoothness } => sketch_ink::stamp(px, w, h, space, balance, smoothness),
PixelFx::Plaster { image_balance, smoothness, light } => sketch::plaster(px, w, h, space, image_balance, smoothness, light),
PixelFx::Reticulation { density, foreground, background } => sketch::reticulation(px, w, h, space, density, foreground, background),
PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)),
PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles),
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
@@ -742,3 +907,7 @@ pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
mod tests_sketch;
+173
View File
@@ -0,0 +1,173 @@
//! Effect › Artistic › Smudge Stick, Sponge and Watercolor. Original filters built from public
//! behaviour documentation: <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/artistic-filters.html> (Smudge Stick smudges the
//! dark areas with short diagonal strokes and brightens the light ones, Sponge paints highly
//! textured areas of contrasting colour, Watercolor simplifies detail with a medium brush loaded
//! with water and saturates the colour where tones change at edges). Visual approximations, not
//! reproductions of a proprietary algorithm; the ranges and defaults are chosen for VectorCraft.
//!
//! All three keep the object's coverage, so they paint nothing outside it. Their patterns lie in
//! document space around the object's centre, as the Brush Strokes' and Texture filters' do.
use super::blur;
use super::brushstrokes::{MARGIN, RIGHT, Smear, TONE_SOFTNESS, edges, mix, ramp, streak, stroke_reach, unpremultiply};
use super::pixelate::Place;
use super::texture::{Tones, luma, premultiply, smooth_noise, straight};
use super::{PixelSpace, Px16, gaussian, to16};
const SALT_SMUDGE: u64 = 320;
const SALT_SPONGE: u64 = 328;
const SALT_WATER: u64 = 336;
/// The length (points) of Smudge Stick's short strokes for Stroke Length `stroke` (0..10); the
/// light areas are smudged with strokes twice as long.
fn smudge_length(stroke: f64) -> f64 {
1.0 + 1.5 * stroke.clamp(0.0, 10.0)
}
/// How far (document units) Smudge Stick reaches for Stroke Length `stroke`: its strokes and the
/// softened tones that choose them.
pub(super) fn smudge_reach(stroke: f64) -> f64 {
stroke_reach(2.0 * smudge_length(stroke)).max(3.0 * TONE_SOFTNESS + MARGIN)
}
/// The σ (points) Sponge softens the object by, for Brush Size `brush` (0..10) and Smoothness
/// `smoothness` (1..15).
fn sponge_softness(brush: f64, smoothness: f64) -> f64 {
(0.15 + 0.04 * smoothness.clamp(1.0, 15.0)) * sponge_pore(brush)
}
/// The size (points) of Sponge's pores for Brush Size `brush` (0..10).
fn sponge_pore(brush: f64) -> f64 {
2.0 + 1.5 * brush.clamp(0.0, 10.0)
}
/// How far (document units) Sponge reaches: the softened colours around a pixel.
pub(super) fn sponge_reach(brush: f64, smoothness: f64) -> f64 {
3.0 * sponge_softness(brush, smoothness) + MARGIN
}
/// The radius (points) of the brush that simplifies detail for Brush Detail `detail` (1..14): the
/// less detail, the wider the brush.
fn water_brush(detail: f64) -> f64 {
0.5 * (15.0 - detail.clamp(1.0, 14.0))
}
/// The σ (points) of the softening that finds Watercolor's edges.
const WATER_EDGE: f64 = 0.6;
/// How far (document units) Watercolor reaches for Brush Detail `detail`.
pub(super) fn watercolor_reach(detail: f64) -> f64 {
water_brush(detail) + 3.0 * WATER_EDGE + MARGIN
}
/// Smudge Stick: the dark areas smudged along short diagonal strokes `stroke` (0..10) long, the
/// light areas smudged with longer strokes and lightened towards white as strongly as `intensity`
/// (0..10) says, so they lose their detail; `highlight` (0..20) widens the area counted as light.
pub(super) fn smudge_stick(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, stroke: f64, highlight: f64, intensity: f64) {
if ![stroke, highlight, intensity].iter().all(|v| v.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let len = smudge_length(stroke);
let (short, long) = (Smear::new(px, w, h, &place, len), Smear::new(px, w, h, &place, 2.0 * len));
let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px);
// Tones above the threshold count as light; a wider highlight area lowers it.
let threshold = 0.9 - 0.5 * (highlight / 20.0).clamp(0.0, 1.0);
let lift = (0.7 * intensity / 10.0).clamp(0.0, 0.7) as f32;
let row = (0.25 * len).max(1.0);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let own = straight(*p);
let paint = |smear: &Smear, salt: u64| {
let (c, a) = unpremultiply(smear.stroke(salt, q, RIGHT, row));
if a > 0.0 { c } else { own }
};
// Bristle marks along the dark strokes.
let bristle = (1.0 + 0.08 * streak(SALT_SMUDGE + 2, q, RIGHT, len, 0.5)) as f32;
let dark = paint(&short, SALT_SMUDGE).map(|v| v * bristle);
let light = paint(&long, SALT_SMUDGE + 1);
let t = ramp((tones.get(x, y) - threshold) / 0.2 + 0.5) as f32;
let bright = light.map(|v| v + (1.0 - v) * lift);
*p = premultiply(mix(dark, bright, t), f32::from(p[3]) / 255.0);
});
}
/// Sponge: the object as if dabbed with a sponge, soft blobs of lighter and darker colour about
/// `brush` (0..10) large; `definition` (0..25) sets how strongly they contrast and `smoothness`
/// (1..15) how soft their edges are.
pub(super) fn sponge(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, brush: f64, definition: f64, smoothness: f64) {
if ![brush, definition, smoothness].iter().all(|v| v.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let mut soft: Vec<Px16> = to16(px);
gaussian(&mut soft, w, sponge_softness(brush, smoothness) / place.px);
let pore = sponge_pore(brush);
let edge = 0.12 + 0.03 * smoothness.clamp(1.0, 15.0);
let amount = (definition / 25.0).clamp(0.0, 1.0);
let gain = 1.0 + 1.2 * amount;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let alpha = f32::from(p[3]) / 255.0;
let own = straight(*p);
let (c, a) = unpremultiply(soft.get(y * w + x).map_or([0.0; 4], |s| s.map(|v| f32::from(v) / 65535.0)));
let c = if a > 0.0 { c } else { own };
let q = place.at(x, y);
let n = 0.65 * smooth_noise(SALT_SPONGE, q, pore) + 0.35 * smooth_noise(SALT_SPONGE + 1, q, 0.45 * pore);
// -1 inside a dark pore, 1 on a light bridge between pores.
let blob = 2.0 * ramp(0.5 + n / (2.0 * edge)) - 1.0;
let l = f64::from(luma(c));
let target = (0.5 + (l + 0.55 * amount * blob - 0.5) * gain).clamp(0.0, 1.0);
// Darker towards black, lighter towards white: the hue stays.
let out = if target <= l {
let k = if l > 1e-3 { (target / l) as f32 } else { 0.0 };
c.map(|v| v * k)
} else {
let k = ((target - l) / (1.0 - l).max(1e-3)).clamp(0.0, 1.0) as f32;
c.map(|v| v + (1.0 - v) * k)
};
*p = premultiply(out, alpha);
});
}
/// Watercolor: the object's detail simplified by a brush whose size `detail` (1..14) sets (the
/// larger, the more detail stays), the colour saturated and its pigment pooled where tones change
/// at edges, the shadows deepened as strongly as `shadow` (0..10) says, and `texture` (1..3)
/// grains of paper showing through.
pub(super) fn watercolor(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, shadow: f64, texture: u32) {
if ![detail, shadow].iter().all(|v| v.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let coverage: Vec<u8> = px.iter().map(|p| p[3]).collect();
// Simplify: average within areas of similar colour, keeping the bolder edges; a coarser brush
// (less detail) also merges colours further apart.
blur::smart(px, w, h, water_brush(detail) / place.px, 20.0 + 6.0 * (14.0 - detail.clamp(1.0, 14.0)), 7);
let found = edges(px, w, h, &place, WATER_EDGE);
let shadow = shadow.clamp(0.0, 10.0);
let paper = 0.02 * f64::from(texture.clamp(1, 3));
let grain = 1.0 + 0.5 * f64::from(texture.clamp(1, 3));
place.each(px, |x, y, p| {
let alpha = coverage.get(y * w + x).copied().unwrap_or(0);
if alpha == 0 {
*p = [0; 4];
return;
}
let c = straight(*p);
let e = (2.0 * f64::from(found.get(y * w + x).copied().unwrap_or(0.0))).clamp(0.0, 1.0) as f32;
let l = luma(c);
// Saturated colour and darker pigment where the tones change.
let c = c.map(|v| (l + (v - l) * (1.0 + 0.9 * e)) * (1.0 - 0.12 * e));
let deep = ((1.0 - l / 0.5).clamp(0.0, 1.0) * 0.07 * shadow as f32).min(0.8);
let q = place.at(x, y);
let n = 0.6 * smooth_noise(SALT_WATER, q, grain) + 0.4 * smooth_noise(SALT_WATER + 1, q, 0.5 * grain);
let n = (paper * n) as f32;
*p = premultiply(c.map(|v| v * (1.0 - deep) + n), f32::from(alpha) / 255.0);
});
}
+295 -7
View File
@@ -1,12 +1,42 @@
//! Effect › Sketch › Halftone Pattern. Original filter built from public behaviour documentation:
//! Effect › Sketch (Chalk & Charcoal, Chrome, Halftone Pattern, Note Paper, Plaster and Reticulation; Graphic Pen lives in
//! [`super::brushstrokes`]). Original filters built from public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
//! black and white; Halftone Pattern simulates a halftone screen while keeping the range of tones)
//! and <https://helpx.adobe.com/illustrator/using/effects.html> (only Size depends on the raster
//! resolution). A visual approximation, not a reproduction of a proprietary algorithm.
//! black and white) and the public Photoshop Elements Artistic filters guide. Visual
//! approximations, not proprietary reproductions.
use super::pixelate::Place;
use super::texture::{luma, premultiply, straight};
use super::{PixelSpace, pick};
use std::f64::consts::{FRAC_1_SQRT_2, TAU};
use vectorcraft_geom::Vec2;
use super::edges::sobel;
use super::pixelate::{Place, cell, rand, value_noise};
use super::texture::{Light, Tones, luma, premultiply, smooth_noise, straight};
use super::{PixelSpace, blur_plane, pick};
const SALT_CHALK: u64 = 216;
const SALT_CHROME: u64 = 0xC40_4D45;
const EDGE_SOFT: f64 = 0.8;
const TONE_SOFT: f64 = 1.0;
/// Light from the top left (document axes, y down), as on polished relief surfaces.
const CHROME_LIGHT: Vec2 = Vec2::new(-FRAC_1_SQRT_2, -FRAC_1_SQRT_2);
/// How far (document units) Chalk & Charcoal samples softened tones and edges.
pub(super) const CHALK_CHARCOAL_REACH: f64 = 3.0 * TONE_SOFT + 1.0 + 3.0 * EDGE_SOFT;
fn ramp(t: f64) -> f64 {
t.clamp(0.0, 1.0)
}
fn tone_edges(px: &[[u8; 4]], w: usize, h: usize, place: &Place, sigma: f64) -> Vec<f32> {
let mut tone: Vec<f32> = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect();
let mut cover: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let s = sigma / place.px;
blur_plane(&mut tone, w, h, s);
blur_plane(&mut cover, w, h, s);
let norm = (s.max(0.8) * TAU.sqrt() / 8.0) as f32;
let norm = if norm.is_finite() { norm } else { 0.0 };
sobel(&tone, w, h).iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c) * norm).collect()
}
/// Halftone Pattern's screens, in its Pattern Type menu's order: (label, parameter value).
pub const HALFTONE_TYPES: [(&str, &str); 3] = [("Circle", "circle"), ("Dot", "dot"), ("Line", "line")];
@@ -69,3 +99,261 @@ pub(super) fn halftone_pattern(px: &mut [[u8; 4]], w: usize, h: usize, space: &P
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
});
}
/// Note Paper: the object embossed on fibrous paper. `balance` (0..50) shifts tones toward
/// highlights or shadows around the middle; `graininess` (0..10) adds paper fibre; `relief`
/// (0..50, points) is how strongly the tones stand out in the light. Coverage is kept.
pub(super) fn note_paper(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, graininess: f64, relief: f64) {
if !(balance.is_finite() && graininess.is_finite() && relief.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let height: Vec<f32> = px
.iter()
.map(|p| {
if p[3] == 0 {
0.0
} else {
let mut l = f64::from(luma(straight(*p)));
l += (balance - 25.0) / 125.0;
l.clamp(0.0, 1.0) as f32
}
})
.collect();
let sample = |x: i32, y: i32| -> f32 {
if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 {
return 0.0;
}
height.get(y as usize * w + x as usize).copied().unwrap_or(0.0)
};
let emboss = relief / 50.0;
let grain = graininess / 10.0;
let light = (-0.6_f64, -0.6, 0.8);
let ln = (light.0 * light.0 + light.1 * light.1 + light.2 * light.2).sqrt().max(1e-9);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let xi = x as i32;
let yi = y as i32;
let h0 = height.get(y * w + x).copied().unwrap_or(0.0);
let dx = (sample(xi + 1, yi) - sample(xi - 1, yi)) as f64;
let dy = (sample(xi, yi + 1) - sample(xi, yi - 1)) as f64;
let dz = emboss * 0.12;
let nx = -dx;
let ny = -dy;
let nz = dz;
let nn = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-9);
let shade = ((nx * light.0 + ny * light.1 + nz * light.2) / (nn * ln) * 0.5 + 0.5).clamp(0.0, 1.0);
let tone = (0.35 + 0.65 * f64::from(h0)) * shade;
let q = place.at(x, y);
let fibre = if grain > 0.0 { value_noise(0x4e4f_5450, q.x * 0.35, q.y * 0.35) * grain * 0.18 } else { 0.0 };
let v = (tone + fibre).clamp(0.0, 1.0) as f32;
*p = premultiply([v; 3], f32::from(p[3]) / 255.0);
});
}
/// Chalk & Charcoal: light areas get chalky highlights and dark areas charcoal strokes and grain;
/// `chalk` and `charcoal` (0..20) set how strongly, `pressure` (0..5) the stroke weight. Black
/// chalk and charcoal on white paper, coverage kept.
pub(super) fn chalk_charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, chalk: f64, charcoal: f64, pressure: f64) {
if !(chalk.is_finite() && charcoal.is_finite() && pressure.is_finite()) {
return;
}
let scale = (pressure.clamp(0.0, 5.0) / 5.0).clamp(0.0, 1.0);
let chalk_amt = (0.05 * chalk.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
let charcoal_amt = (0.05 * charcoal.clamp(0.0, 20.0) * scale).clamp(0.0, 1.0);
if chalk_amt <= 0.0 && charcoal_amt <= 0.0 {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let tones = Tones::new(px, w, h, TONE_SOFT / place.px);
let edges = tone_edges(px, w, h, &place, EDGE_SOFT);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let tone = tones.get(x, y);
let dark = (1.0 - tone).clamp(0.0, 1.0);
let light = tone.clamp(0.0, 1.0);
let edge = f64::from(edges.get(y * w + x).copied().unwrap_or(0.0));
let charcoal_stroke = value_noise(SALT_CHALK + 1, q.x * 1.1, q.y * 0.12);
let charcoal_grain = value_noise(SALT_CHALK, q.x * 0.35, q.y * 0.35);
let charcoal_mask = ramp((dark - 0.3) / 0.22)
* charcoal_amt
* (0.35 + 0.65 * edge)
* (0.45 + 0.55 * ramp(charcoal_stroke * 2.2 - 0.55))
* (0.6 + 0.4 * charcoal_grain);
let chalk_grain = value_noise(SALT_CHALK + 2, q.x * 0.45 + 7.0, q.y * 0.45);
let chalk_mask = ramp((light - 0.5) / 0.22) * chalk_amt * (0.55 + 0.45 * chalk_grain);
let down = (0.9 * charcoal_mask).clamp(0.0, 1.0) as f32;
let up = (0.8 * chalk_mask).clamp(0.0, 1.0) as f32;
let mut grey = luma(straight(*p));
grey *= 1.0 - down;
grey += (1.0 - grey) * up;
*p = premultiply([grey, grey, grey], f32::from(p[3]) / 255.0);
});
}
/// How far (document units) Chrome reaches for `smoothness` (0..10): 3 σ of the height-field blur
/// plus the Sobel neighbours.
pub(super) fn chrome_reach(smoothness: f64) -> f64 {
let smoothness = smoothness.clamp(0.0, 10.0);
3.0 * (0.4 + 0.55 * smoothness) + 1.0
}
/// Chrome: the object as a polished chrome surface lit from the top left. `detail` (0..10) adds
/// finer relief; `smoothness` (0..10) softens the height field before the highlights and shadows
/// are drawn. Coverage is kept.
pub(super) fn chrome(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, smoothness: f64) {
if w == 0 || h == 0 {
return;
}
let detail = detail.clamp(0.0, 10.0);
let smoothness = smoothness.clamp(0.0, 10.0);
let Some(place) = Place::new(space, w, h) else { return };
let sigma = (0.4 + smoothness * 0.55) / place.px.max(1e-9);
let cell = (14.0 - detail * 1.2).max(1.5);
let bump = (detail / 10.0) * 0.4;
let strength = 2.5 + detail * 0.35;
let mut height: Vec<f32> = Vec::with_capacity(w * h);
let mut alpha: Vec<f32> = Vec::with_capacity(w * h);
for y in 0..h {
for x in 0..w {
let p = px[y * w + x];
let a = f32::from(p[3]) / 255.0;
alpha.push(a);
if a <= 0.0 {
height.push(0.0);
continue;
}
let q = place.at(x, y);
let base = f64::from(luma(straight(p)));
let n = if bump > 0.0 { bump * smooth_noise(SALT_CHROME, q, cell) } else { 0.0 };
height.push((base + n).clamp(0.0, 1.0) as f32);
}
}
blur_plane(&mut height, w, h, sigma);
blur_plane(&mut alpha, w, h, sigma);
let tone: Vec<f64> = height.iter().zip(&alpha).map(|(h, a)| if *a > 1e-4 { f64::from(*h / *a).clamp(0.0, 1.0) } else { 0.0 }).collect();
let px_unit = place.px.max(1e-9);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let at = |dx: isize, dy: isize| {
let (xx, yy) = ((x as isize + dx).clamp(0, w as isize - 1) as usize, (y as isize + dy).clamp(0, h as isize - 1) as usize);
tone[yy * w + xx]
};
let h0 = tone[y * w + x];
let gx = 0.5 * (at(1, 0) - at(-1, 0)) / px_unit;
let gy = 0.5 * (at(0, 1) - at(0, -1)) / px_unit;
let face = -(gx * CHROME_LIGHT.x + gy * CHROME_LIGHT.y);
let spec = (face * strength + (h0 - 0.5) * 1.5).tanh();
let grey = (0.5 + 0.5 * spec).clamp(0.0, 1.0);
*p = premultiply([grey as f32; 3], f32::from(p[3]) / 255.0);
});
}
/// Colour `c` lit by `s`: darkened towards black below 0, lightened towards white above.
fn lit(c: [f32; 3], s: f64) -> [f32; 3] {
let s = if s.is_finite() { s.clamp(-1.0, 1.0) as f32 } else { 0.0 };
c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s })
}
/// How a surface whose height rises along `slope` faces a light from `toward`.
fn facing(slope: Vec2, toward: Vec2) -> f64 {
-slope.dot(toward)
}
/// The σ (document units) of Plaster's height blur at `smoothness` (1..15).
fn plaster_sigma_pt(smoothness: f64) -> f64 {
let smoothness = smoothness.clamp(1.0, 15.0);
0.15 + 0.85 * (smoothness - 1.0) / 14.0
}
/// How far (document units) Plaster's softened relief reaches at `smoothness` (1..15).
pub(super) fn plaster_reach(smoothness: f64) -> f64 {
if !smoothness.is_finite() {
return 3.0 * plaster_sigma_pt(8.0) + 0.5;
}
3.0 * plaster_sigma_pt(smoothness) + 0.5
}
/// Plaster: the object molded in relief plaster and lit from `light`. Dark areas are raised and
/// light areas recessed; `imageBalance` (1..50) shifts more of the image lower at higher values;
/// `smoothness` (1..15) softens the relief. Coverage is kept.
pub(super) fn plaster(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, image_balance: f64, smoothness: f64, light: Light) {
if !(image_balance.is_finite() && smoothness.is_finite() && space.px.is_finite() && space.px > 0.0)
|| w == 0
|| h == 0
|| w.checked_mul(h) != Some(px.len())
{
return;
}
let image_balance = image_balance.clamp(1.0, 50.0);
let smoothness = smoothness.clamp(1.0, 15.0);
let bias = (image_balance - 1.0) / 49.0;
let mut height: Vec<f32> = px
.iter()
.map(|p| {
let a = f32::from(p[3]) / 255.0;
let dark = (1.0 - luma(straight(*p))) * a;
(dark - bias as f32 * 0.65).clamp(0.0, 1.0)
})
.collect();
blur_plane(&mut height, w, h, plaster_sigma_pt(smoothness) / space.px);
let Some(place) = Place::new(space, w, h) else { return };
let step = (0.35f64).max(0.5 * place.px);
let strength = 0.55;
let toward = light.toward();
let at = |x: usize, y: usize, dx: i32, dy: i32| {
let (xx, yy) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);
height[yy * w + xx]
};
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let h = height[y * w + x];
let (left, right, up, down) = (at(x, y, -1, 0), at(x, y, 1, 0), at(x, y, 0, -1), at(x, y, 0, 1));
let slope = Vec2::new(f64::from(right - left), f64::from(down - up)) / (2.0 * step);
let ridge = f64::from(h - 0.25 * (left + right + up + down)) / step;
let shade = strength * (facing(slope, toward) + ridge);
*p = premultiply(lit(straight(*p), shade), f32::from(p[3]) / 255.0);
});
}
/// Reticulation: the object redrawn in black ink on white paper through a high-contrast film-grain
/// screen. `density` (0..50) sets how fine the grain is; `foreground` and `background` (0..50 each)
/// pull shadows toward black and highlights toward white before the grain decides ink. Coverage is
/// kept.
pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, density: f64, foreground: f64, background: f64) {
if !(density.is_finite() && foreground.is_finite() && background.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
// Finer grain at higher density: cells from about 11 pt down to 1 pt.
let cell_size = 1.0 + (50.0 - density.clamp(0.0, 50.0)) * 0.2;
let fg = foreground.clamp(0.0, 50.0) / 50.0;
let bg = background.clamp(0.0, 50.0) / 50.0;
let spread = 0.12 + 0.38 * (density.clamp(0.0, 50.0) / 50.0);
const SALT: u64 = 0x5245_7469;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let dark = f64::from(1.0 - luma(straight(*p))).clamp(0.0, 1.0);
let tone = (dark + fg * (1.0 - dark) * 0.65 - bg * dark * 0.65).clamp(0.0, 1.0);
let coarse = value_noise(SALT, q.x / cell_size, q.y / cell_size);
let fine = match (cell(q.x), cell(q.y)) {
(Some(i), Some(j)) => rand(SALT + 1, i, j, 2),
_ => 0.5,
};
let grain = 0.55 * coarse + 0.45 * fine;
let ink = if tone + (grain - 0.5) * spread > 0.5 { 1.0 } else { 0.0 };
*p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0);
});
}
+209
View File
@@ -0,0 +1,209 @@
//! Effect › Sketch › Bas Relief, Charcoal, Photocopy and Stamp. Original filters built from public
//! behaviour documentation: <https://helpx.adobe.com/illustrator/using/summary-effects.html> and
//! <https://helpx.adobe.com/photoshop-elements/using/sketch-filters.html> (the Sketch effects
//! redraw the object in the foreground and background colours, black on white here; Bas Relief
//! carves low relief lit from a direction, Charcoal draws bold edges and sketches the midtones in
//! diagonal strokes, Photocopy copies only the edges of large dark areas and takes the midtones to
//! black or white, Stamp simplifies the image to solid black and white as a rubber stamp would).
//! Visual approximations, not reproductions of a proprietary algorithm; the ranges and defaults
//! are chosen for VectorCraft.
//!
//! All four keep the object's coverage, so they paint nothing outside it. Their sizes are in
//! document points, whatever the raster's resolution.
use std::f64::consts::FRAC_1_SQRT_2;
use super::pixelate::{Place, cell, value_noise};
use super::texture::{Light, Tones, premultiply};
use super::{PixelSpace, blur_plane};
const SALT_STROKES: u64 = 440;
/// A pixel's size (points) at 72 ppi: how far the neighbours of a gradient add to a filter's reach.
const MARGIN: f64 = 1.0;
/// Redraw `p` as ink `ink` (0..1) of black on white paper, keeping its coverage.
fn paint(p: &mut [u8; 4], ink: f64) {
let v = if ink.is_finite() { (1.0 - ink).clamp(0.0, 1.0) as f32 } else { 1.0 };
*p = premultiply([v; 3], f32::from(p[3]) / 255.0);
}
/// A smooth step from 0 at `t` ≤ 0 to 1 at `t` ≥ 1 (0 when `t` is not a number).
fn ramp(t: f64) -> f64 {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
t * t * (3.0 - 2.0 * t)
}
/// The tone at (`x`, `y`) and its gradient per pixel, from central differences.
fn gradient(tones: &Tones, x: usize, y: usize) -> (f64, f64, f64) {
let gx = 0.5 * (tones.get(x + 1, y) - tones.get(x.saturating_sub(1), y));
let gy = 0.5 * (tones.get(x, y + 1) - tones.get(x, y.saturating_sub(1)));
(tones.get(x, y), gx, gy)
}
/// The σ (points) Bas Relief softens the object by, for Detail `detail` (1..15) and Smoothness
/// `smoothness` (1..15).
fn relief_softness(detail: f64, smoothness: f64) -> f64 {
0.2 + 0.15 * smoothness.clamp(1.0, 15.0) + 0.1 * (15.0 - detail.clamp(1.0, 15.0))
}
/// How far (document units) Bas Relief reaches: the softened tones and their neighbours.
pub(super) fn relief_reach(detail: f64, smoothness: f64) -> f64 {
3.0 * relief_softness(detail, smoothness) + MARGIN
}
/// Bas Relief: the object carved in low relief and lit from `light`. Surfaces facing the light turn
/// white, those turned away black, flat areas grey with a hint of the object's own tones; `detail`
/// (1..15) keeps finer features and `smoothness` (1..15) rounds the carving.
pub(super) fn bas_relief(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, smoothness: f64, light: Light) {
if !(detail.is_finite() && smoothness.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let sigma = relief_softness(detail, smoothness);
let tones = Tones::new(px, w, h, sigma / place.px);
let toward = light.toward();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let (tone, slope) = tones.slope(x, y, &place);
// A step from black to white, softened by σ, rises 0.4 per σ at its steepest.
let lit = -slope.dot(toward) * sigma;
paint(p, 1.0 - (0.5 + 0.2 * (tone - 0.5) + 2.5 * lit));
});
}
/// The σ (points) Charcoal softens the object by to find its edges, for Detail `detail` (0..5).
fn charcoal_softness(detail: f64) -> f64 {
0.25 + 0.25 * (5.0 - detail.clamp(0.0, 5.0)) + 0.25
}
/// The σ (points) that thickens Charcoal's edges, for Thickness `thickness` (1..7).
fn charcoal_thickening(thickness: f64) -> f64 {
0.35 * thickness.clamp(1.0, 7.0)
}
/// The distance (points) between the strokes sketching Charcoal's midtones.
fn charcoal_spacing(thickness: f64) -> f64 {
1.5 + 0.5 * thickness.clamp(1.0, 7.0)
}
/// How far (document units) Charcoal reaches: the edges, their thickening and the strokes' tones.
pub(super) fn charcoal_reach(thickness: f64, detail: f64) -> f64 {
(3.0 * (charcoal_softness(detail) + charcoal_thickening(thickness)) + 2.0 * MARGIN).max(2.0 * charcoal_spacing(thickness) + MARGIN)
}
/// Charcoal: bold, `thickness` (1..7) wide edges, as fine as `detail` (0..5) says, and the
/// midtones sketched in diagonal strokes; `balance` (0..100) sets how far into the lights the
/// charcoal reaches (50 is even).
pub(super) fn charcoal(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, detail: f64, balance: f64) {
if !(thickness.is_finite() && detail.is_finite() && balance.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let sigma = charcoal_softness(detail);
let spacing = charcoal_spacing(thickness);
let bias = (balance.clamp(0.0, 100.0) - 50.0) / 100.0 * 0.8;
// The edges: how much the softened tones change across σ, 1 for a step from black to white.
let edge_tones = Tones::new(px, w, h, sigma / place.px);
let mut edge: Vec<f32> = (0..w * h)
.map(|k| {
let (_, gx, gy) = gradient(&edge_tones, k % w, k / w);
let contrast = gx.hypot(gy) / place.px * sigma / 0.4;
let weakest = 0.35 - 0.2 * bias / 0.4;
ramp((contrast - weakest) / 0.3) as f32
})
.collect();
blur_plane(&mut edge, w, h, charcoal_thickening(thickness) / place.px);
// The strokes' tones are softer than the edges', so the strokes don't follow every speck.
let tones = Tones::new(px, w, h, 0.5 * spacing / place.px);
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let bold = edge.get(y * w + x).map_or(0.0, |e| ((f64::from(*e) - 0.3) / 0.3).clamp(0.0, 1.0));
let dark = 1.0 - tones.get(x, y);
let share = ((dark + bias - 0.2) / 0.6).clamp(0.0, 1.0);
let q = place.at(x, y);
// Across the strokes, which run rising to the right, and along them.
let (across, along) = ((q.x + q.y) * FRAC_1_SQRT_2 / spacing, (q.x - q.y) * FRAC_1_SQRT_2 / spacing);
let stroke = match (share > 0.0, cell(across)) {
(true, Some(line)) => {
let f = across - line as f64;
// Each stroke breaks and swells along its length.
let swell = (0.6 + 0.8 * value_noise(SALT_STROKES, along / 6.0, line as f64)).clamp(0.0, 1.2);
let reach = (share * swell).min(1.0);
let soft = 2.0 * place.px / spacing;
(((reach - (2.0 * f - 1.0).abs()) / soft + 0.5).clamp(0.0, 1.0)) * f64::from(reach > 0.0)
}
_ => 0.0,
};
paint(p, bold.max(stroke));
});
}
/// The σ (points) of Photocopy's narrow softening for Detail `detail` (1..24): the less detail,
/// the wider.
fn copy_softness(detail: f64) -> f64 {
0.15 * (25.0 - detail.clamp(1.0, 24.0))
}
/// How much wider Photocopy's second softening is than its first.
const COPY_WIDER: f64 = 3.0;
/// How far (document units) Photocopy reaches: the wide softening.
pub(super) fn copy_reach(detail: f64) -> f64 {
3.0 * COPY_WIDER * copy_softness(detail) + MARGIN
}
/// Photocopy: black where a pixel is darker than its surroundings, white elsewhere. Large dark
/// areas copy only around their edges and midtones go to black or white. `detail` (1..24) keeps
/// finer features and `darkness` (1..50) copies more of the dark.
pub(super) fn photocopy(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, detail: f64, darkness: f64) {
if !(detail.is_finite() && darkness.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let sigma = copy_softness(detail) / place.px;
let narrow = Tones::new(px, w, h, sigma);
let wide = Tones::new(px, w, h, COPY_WIDER * sigma);
// How much darker than its surroundings a pixel must be to print.
let threshold = 0.02 + 0.10 * (1.0 - (darkness.clamp(1.0, 50.0) - 1.0) / 49.0);
let soft = 0.02;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
paint(p, ((wide.get(x, y) - narrow.get(x, y) - threshold) / soft + 0.5).clamp(0.0, 1.0));
});
}
/// The σ (points) Stamp softens the object by for Smoothness `smoothness` (1..50).
fn stamp_softness(smoothness: f64) -> f64 {
0.1 * smoothness.clamp(1.0, 50.0)
}
/// How far (document units) Stamp reaches: the softened tones and their neighbours.
pub(super) fn stamp_reach(smoothness: f64) -> f64 {
3.0 * stamp_softness(smoothness) + MARGIN
}
/// Stamp: the object as solid black and white, like a rubber stamp. `balance` (0..50) sets how much
/// of the object prints black and `smoothness` (1..50) rounds the shapes.
pub(super) fn stamp(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, balance: f64, smoothness: f64) {
if !(balance.is_finite() && smoothness.is_finite()) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let tones = Tones::new(px, w, h, stamp_softness(smoothness) / place.px);
let threshold = 0.2 + 0.6 * balance.clamp(0.0, 50.0) / 50.0;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let (tone, gx, gy) = gradient(&tones, x, y);
// A one pixel wide edge, whatever the softening.
paint(p, ((threshold - tone) / gx.hypot(gy).max(0.01) + 0.5).clamp(0.0, 1.0));
});
}
+256 -1
View File
@@ -1998,6 +1998,47 @@ fn brush_strokes_follow_the_object_at_any_resolution() {
}
}
/// Chalk & Charcoal of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn chalk_charcoal(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.chalkCharcoal", p).apply(&mut out, w, h, s);
out
}
#[test]
fn chalk_charcoal_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.chalkCharcoal", json!({})), PixelFx::ChalkCharcoal { chalk: 6.0, charcoal: 6.0, pressure: 2.0 });
assert_eq!(
fx("sketch.chalkCharcoal", json!({"chalkArea": 1e9, "charcoalArea": -2, "strokePressure": 99})),
PixelFx::ChalkCharcoal { chalk: 20.0, charcoal: 0.0, pressure: 5.0 }
);
let doc = crate::effect_info("sketch.chalkCharcoal").unwrap().params;
assert!(doc.contains("chalkArea") && doc.contains("charcoalArea") && doc.contains("strokePressure"), "{doc}");
let f = fx("sketch.chalkCharcoal", json!({"strokePressure": 5}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert!(f.reach().unwrap() > 0.0);
}
#[test]
fn chalk_charcoal_draws_in_black_and_white_only() {
let (w, h) = (48, 48);
let src = image(w, h, |x, y| {
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
let a: u16 = if y < 24 { 255 } else { 128 };
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
});
let out = chalk_charcoal(json!({"chalkArea": 12, "charcoalArea": 12, "strokePressure": 4}), &src, w, h, &space(w, h));
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], q[3], "coverage kept");
if p[3] == 0 {
continue;
}
assert_eq!(p[0], p[1]);
assert_eq!(p[1], p[2]);
}
assert_ne!(out, src);
}
/// Halftone Pattern of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
@@ -2010,6 +2051,38 @@ fn period(values: &[u8]) -> usize {
(1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len())
}
#[test]
fn graphic_pen_params_take_defaults_and_stay_in_range() {
use super::STROKE_DIRECTIONS;
assert_eq!(fx("sketch.graphicPen", json!({})), PixelFx::GraphicPen { length: 5.0, balance: 50.0, direction: StrokeDirection::RightDiagonal });
assert_eq!(
fx("sketch.graphicPen", json!({"strokeLength": 0, "lightDarkBalance": 200, "strokeDirection": "VERTICAL"})),
PixelFx::GraphicPen { length: 1.0, balance: 100.0, direction: StrokeDirection::Vertical }
);
let doc = crate::effect_info("sketch.graphicPen").unwrap().params;
assert!(STROKE_DIRECTIONS.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
let f = fx("sketch.graphicPen", json!({"strokeLength": 15}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), brushstrokes::stroke_reach(15.0));
assert_eq!(f.reach(), Some(brushstrokes::stroke_reach(15.0).max(brushstrokes::TONE_REACH)));
assert_eq!(
fx("sketch.graphicPen", json!({"strokeLength": 99})),
PixelFx::GraphicPen { length: 15.0, balance: 50.0, direction: StrokeDirection::RightDiagonal }
);
}
/// Graphic Pen on a mid-grey field draws both black ink and white paper.
#[test]
fn graphic_pen_draws_ink_and_paper_on_grey() {
let (w, h) = (48, 48);
let src = image(w, h, |_, _| grey(32));
let mut out = src.clone();
fx("sketch.graphicPen", json!({"strokeLength": 4, "lightDarkBalance": 50})).apply(&mut out, w, h, &space(w, h));
let lumas: Vec<u8> = out.as_chunks::<4>().0.iter().map(|p| p[0]).collect();
let (min, max) = lumas.iter().fold((255u8, 0u8), |(lo, hi), l| (lo.min(*l), hi.max(*l)));
assert!(min < 80 && max > 200, "min {min} max {max}");
assert_ne!(out, src, "the filter should change the raster");
}
#[test]
fn halftone_pattern_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.halftonePattern", json!({})), PixelFx::HalftonePattern { size: 1.0, contrast: 5.0, kind: HalftonePattern::Dot });
@@ -2033,6 +2106,44 @@ fn halftone_pattern_params_take_defaults_and_stay_in_range() {
assert_eq!(f.reach(), Some(0.0));
}
#[test]
fn note_paper_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.notePaper", json!({})), PixelFx::NotePaper { balance: 25.0, graininess: 0.0, relief: 4.0 });
assert_eq!(
fx("sketch.notePaper", json!({"imageBalance": -1, "graininess": 20, "relief": 100})),
PixelFx::NotePaper { balance: 0.0, graininess: 10.0, relief: 50.0 }
);
let doc = crate::effect_info("sketch.notePaper").unwrap().params;
assert!(doc.contains("imageBalance") && doc.contains("graininess") && doc.contains("relief"), "{doc}");
let f = fx("sketch.notePaper", json!({"relief": 12}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(0.0));
}
/// Note Paper embosses a horizontal gradient and graininess adds fibre.
#[test]
fn note_paper_embosses_a_gradient_and_grain_changes_pixels() {
let (w, h) = (32, 32);
let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8));
let flat = {
let mut out = src.clone();
fx("sketch.notePaper", json!({"relief": 0, "graininess": 0})).apply(&mut out, w, h, &space(w, h));
out
};
let embossed = {
let mut out = src.clone();
fx("sketch.notePaper", json!({"relief": 50, "graininess": 0})).apply(&mut out, w, h, &space(w, h));
out
};
assert_ne!(flat, embossed, "relief changes the emboss");
let grainy = {
let mut out = src.clone();
fx("sketch.notePaper", json!({"relief": 0, "graininess": 10})).apply(&mut out, w, h, &space(w, h));
out
};
assert_ne!(flat, grainy, "graininess adds fibre");
}
/// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi.
#[test]
fn halftone_line_period_is_the_size_at_any_resolution() {
@@ -2077,6 +2188,41 @@ fn halftone_pattern_type_changes_the_pattern() {
}
}
/// Plaster of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn plaster(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.plaster", p).apply(&mut out, w, h, s);
out
}
#[test]
fn plaster_params_take_defaults_and_stay_in_range() {
use super::texture::Light;
assert_eq!(fx("sketch.plaster", json!({})), PixelFx::Plaster { image_balance: 20.0, smoothness: 8.0, light: Light::Top });
assert_eq!(
fx("sketch.plaster", json!({"imageBalance": 0, "smoothness": 1e9, "lightDirection": "bottomRight"})),
PixelFx::Plaster { image_balance: 1.0, smoothness: 15.0, light: Light::BottomRight }
);
assert_eq!(fx("sketch.plaster", json!({"lightDirection": "nonsense"})), fx("sketch.plaster", json!({})));
let doc = crate::effect_info("sketch.plaster").unwrap().params;
assert!(doc.contains("\"bottomRight\""), "{doc}");
let f = fx("sketch.plaster", json!({"smoothness": 24}));
assert_eq!(f.reach(), Some(super::sketch::plaster_reach(15.0)));
}
/// Plaster changes the image and different light directions give different results.
#[test]
fn plaster_light_direction_shifts_shading() {
let (w, h) = (48, 32);
let src = image(w, h, |x, y| grey((16 + (x + y) % 200) as u8));
let s = space(w, h);
let base = plaster(json!({"imageBalance": 20, "smoothness": 6, "lightDirection": "top"}), &src, w, h, &s);
assert_ne!(base, src, "plaster changes opaque pixels");
let left = plaster(json!({"lightDirection": "left"}), &src, w, h, &s);
let right = plaster(json!({"lightDirection": "right"}), &src, w, h, &s);
assert_ne!(left, right, "light direction changes the relief shading");
}
#[test]
fn halftone_contrast_sharpens_the_screen() {
let (w, h) = (64, 32);
@@ -2114,6 +2260,89 @@ fn halftone_draws_in_black_and_white_only() {
}
}
fn chrome(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.chrome", p).apply(&mut out, w, h, s);
out
}
/// Reticulation of `p` over `w` × `h` pixels of `src`, laid out by `s`.
fn reticulation(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec<u8> {
let mut out = src.to_vec();
fx("sketch.reticulation", p).apply(&mut out, w, h, s);
out
}
#[test]
fn chrome_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.chrome", json!({})), PixelFx::Chrome { detail: 4.0, smoothness: 4.0 });
assert_eq!(fx("sketch.chrome", json!({"detail": -1, "smoothness": 99})), PixelFx::Chrome { detail: 0.0, smoothness: 10.0 });
assert_eq!(fx("sketch.chrome", json!({"detail": "x"})), fx("sketch.chrome", json!({})));
let doc = crate::effect_info("sketch.chrome").unwrap().params;
assert!(doc.contains("detail") && doc.contains("smoothness"), "{doc}");
let f = fx("sketch.chrome", json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(sketch::chrome_reach(4.0)));
assert_eq!(fx("sketch.chrome", json!({"smoothness": 10})).reach(), Some(sketch::chrome_reach(10.0)));
}
/// Chrome redraws in greyscale, keeps coverage, and more detail changes the pattern.
#[test]
fn chrome_draws_polished_greyscale_relief() {
let (w, h) = (64, 64);
let src = image(w, h, |x, y| {
let t = ((x + y) * 4) as u8;
[t, t / 2, 255 - t, 255]
});
let soft = chrome(json!({"detail": 0, "smoothness": 8}), &src, w, h, &space(w, h));
let sharp = chrome(json!({"detail": 10, "smoothness": 0}), &src, w, h, &space(w, h));
assert_ne!(soft, sharp);
for out in [&soft, &sharp] {
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], q[3], "coverage kept");
assert!(p[0] == p[1] && p[1] == p[2], "greyscale {p:?}");
}
assert!(share(out, |p| p[0] > 10 && p[0] < 245) > 0.05, "midtones");
}
}
#[test]
fn reticulation_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("sketch.reticulation", json!({})), PixelFx::Reticulation { density: 12.0, foreground: 6.0, background: 6.0 });
assert_eq!(
fx("sketch.reticulation", json!({"density": -5, "foregroundLevel": 99, "backgroundLevel": -1})),
PixelFx::Reticulation { density: 0.0, foreground: 50.0, background: 0.0 }
);
let f = fx("sketch.reticulation", json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(0.0));
}
#[test]
fn reticulation_draws_in_black_and_white_only() {
let (w, h) = (48, 48);
let src = image(w, h, |x, y| {
let c = [[230u8, 20, 20], [20, 200, 40], [30, 60, 220], [240, 220, 30]][(x / 12) % 4];
let a: u16 = if y < 24 { 255 } else { 128 };
[c[0], c[1], c[2]].map(|v| (u16::from(v) * a / 255) as u8).into_iter().chain([a as u8]).collect::<Vec<_>>().try_into().unwrap()
});
let out = reticulation(json!({}), &src, w, h, &space(w, h));
for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) {
assert_eq!(p[3], q[3], "coverage kept");
assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "{p:?} from {q:?}");
}
assert!(share(&out, |p| p[0] == 0) > 0.05 && share(&out, |p| p[0] == p[3]) > 0.05, "ink and paper");
}
#[test]
fn reticulation_density_changes_the_grain() {
let (w, h) = (64, 64);
let src = image(w, h, |_, _| grey(128));
let low = reticulation(json!({"density": 5}), &src, w, h, &space(w, h));
let high = reticulation(json!({"density": 45}), &src, w, h, &space(w, h));
assert_ne!(low, high);
}
/// The distinct colours of the covered pixels.
fn colours(d: &[u8]) -> std::collections::BTreeSet<[u8; 4]> {
d.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).copied().collect()
@@ -2148,7 +2377,12 @@ fn artistic_params_take_defaults_and_stay_in_range() {
fx("artistic.neonGlow", json!({"glowSize": -1e308, "glowBrightness": 80, "color": "#ff0000"})),
PixelFx::NeonGlow { size: -24.0, brightness: 50.0, colour: [1.0, 0.0, 0.0] }
);
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.posterEdges"] {
assert_eq!(fx("artistic.plasticWrap", json!({})), PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 });
assert_eq!(
fx("artistic.plasticWrap", json!({"highlightStrength": -1, "detail": 99, "smoothness": 0})),
PixelFx::PlasticWrap { highlight: 0.0, detail: 15.0, smoothness: 1.0 }
);
for id in ["artistic.cutout", "artistic.neonGlow", "artistic.plasticWrap", "artistic.posterEdges"] {
assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape");
}
}
@@ -2166,6 +2400,7 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
let filters = [
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 },
PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour },
PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 },
];
for f in &filters {
@@ -2196,6 +2431,26 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}");
}
#[test]
fn plastic_wrap_adds_specular_highlights_and_keeps_coverage() {
let (w, h) = (48, 32);
let src = image(w, h, |x, y| grey((((x + y) * 5) % 200 + 30) as u8));
let mut none = src.clone();
fx("artistic.plasticWrap", json!({"highlightStrength": 0})).apply(&mut none, w, h, &space(w, h));
assert_eq!(none, src);
let mut out = src.clone();
fx("artistic.plasticWrap", json!({})).apply(&mut out, w, h, &space(w, h));
assert!(out != src, "defaults brighten some pixels");
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
let mut weak = src.clone();
let mut strong = src.clone();
fx("artistic.plasticWrap", json!({"highlightStrength": 4})).apply(&mut weak, w, h, &space(w, h));
fx("artistic.plasticWrap", json!({"highlightStrength": 20})).apply(&mut strong, w, h, &space(w, h));
let lift =
|a: &[u8], b: &[u8]| a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).map(|(o, s)| i32::from(o[0]) - i32::from(s[0])).sum::<i32>();
assert!(lift(&strong, &src) > lift(&weak, &src), "stronger highlights lift more");
}
#[test]
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
let (w, h) = (64, 24);
+189
View File
@@ -0,0 +1,189 @@
//! Artistic › Smudge Stick, Sponge and Watercolor.
use serde_json::json;
use vectorcraft_doc::Effect;
use vectorcraft_geom::{Affine, Point};
use super::*;
use crate::{RasterFx, raster_effects};
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
fn space(w: usize, h: usize) -> PixelSpace {
let (w, h) = (w as f64, h as f64);
PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
}
}
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| f(x, y)).collect()
}
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
let e = Effect { id: id.into(), params: p, visible: true };
match raster_effects(&[e]).first() {
Some(RasterFx::Pixel(p)) => p.clone(),
other => panic!("{id}: {other:?}"),
}
}
fn run(id: &str, p: serde_json::Value, src: &[u8], w: usize, h: usize) -> Vec<u8> {
let mut out = src.to_vec();
fx(id, p).apply(&mut out, w, h, &space(w, h));
out
}
fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
/// The mean of channel 0 over the columns `x0..x1`.
fn mean(d: &[u8], w: usize, x0: usize, x1: usize) -> f64 {
let rows = d.len() / 4 / w;
let sum: f64 = (0..rows).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| f64::from(d[(y * w + x) * 4])).sum();
sum / (rows * (x1 - x0)) as f64
}
/// The summed difference between horizontal neighbours of channel 0 over columns `x0..x1`.
fn roughness(d: &[u8], w: usize, x0: usize, x1: usize) -> f64 {
let rows = d.len() / 4 / w;
(0..rows)
.flat_map(|y| (x0..x1 - 1).map(move |x| (x, y)))
.map(|(x, y)| (f64::from(d[(y * w + x) * 4]) - f64::from(d[(y * w + x + 1) * 4])).abs())
.sum()
}
fn spread(d: &[u8]) -> f64 {
let v: Vec<f64> = d.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])).collect();
let m = v.iter().sum::<f64>() / v.len() as f64;
(v.iter().map(|x| (x - m).powi(2)).sum::<f64>() / v.len() as f64).sqrt()
}
/// No panic and the coverage kept on tiny, huge and non-finite spaces and parameters.
fn survives(id: &str, params: &[serde_json::Value]) {
let (w, h) = (9, 7);
let src = image(w, h, |x, y| [(x * 25) as u8, (y * 30) as u8, 90, if x == 0 { 0 } else { 255 }]);
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
let nan = PixelSpace { px: f64::NAN, ..space(w, h) };
for p in params {
for s in [tiny, huge, nan, space(w, h)] {
let mut out = src.clone();
fx(id, p.clone()).apply(&mut out, w, h, &s);
assert_eq!(out.len(), src.len());
}
}
// A size that doesn't match, and an empty raster, do nothing.
let mut odd = vec![1u8; 10];
fx(id, json!({})).apply(&mut odd, 3, 3, &space(3, 3));
assert_eq!(odd, vec![1u8; 10]);
fx(id, json!({})).apply(&mut [], 0, 0, &space(1, 1));
}
#[test]
fn smudge_stick_smudges_the_dark_and_lightens_the_light() {
assert_eq!(fx("artistic.smudgeStick", json!({})), PixelFx::SmudgeStick { stroke: 2.0, highlight: 12.0, intensity: 10.0 });
assert_eq!(
fx("artistic.smudgeStick", json!({"strokeLength": 1e308, "highlightArea": -5, "intensity": "x"})),
PixelFx::SmudgeStick { stroke: 10.0, highlight: 0.0, intensity: 10.0 }
);
// A fine checkerboard of dark tones on the left, of light tones on the right, and a half
// transparent bottom.
let (w, h) = (64, 48);
let src = image(w, h, |x, y| {
let on = (x + y) % 2 == 0;
let v = match (x < 32, on) {
(true, true) => 20,
(true, false) => 70,
(false, true) => 190,
(false, false) => 240,
};
if y < 40 { grey(v) } else { [v / 2, v / 2, v / 2, 128] }
});
let out = run("artistic.smudgeStick", json!({}), &src, w, h);
assert_eq!(out, run("artistic.smudgeStick", json!({}), &src, w, h), "draws the same twice");
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept");
// The dark detail is smudged away, the light areas brighter.
assert!(roughness(&out, w, 4, 28) < 0.5 * roughness(&src, w, 4, 28));
assert!(mean(&out, w, 36, 60) > mean(&src, w, 36, 60) + 10.0);
// Intensity 0 leaves the light areas unlightened.
let flat = run("artistic.smudgeStick", json!({"intensity": 0}), &src, w, h);
assert!(mean(&flat, w, 36, 60) < mean(&out, w, 36, 60) - 5.0);
survives(
"artistic.smudgeStick",
&[json!({}), json!({"strokeLength": 1e308, "highlightArea": 1e308, "intensity": 1e308}), json!({"strokeLength": "NaN"})],
);
}
#[test]
fn sponge_dabs_contrasting_blobs_and_keeps_the_hue() {
assert_eq!(fx("artistic.sponge", json!({})), PixelFx::Sponge { brush: 2.0, definition: 12.0, smoothness: 5.0 });
assert_eq!(
fx("artistic.sponge", json!({"brushSize": -1, "definition": 1e308, "smoothness": 0})),
PixelFx::Sponge { brush: 0.0, definition: 25.0, smoothness: 1.0 }
);
let (w, h) = (80, 60);
let flat = image(w, h, |_, y| if y < 50 { [200, 90, 60, 255] } else { [100, 45, 30, 128] });
let out = run("artistic.sponge", json!({"definition": 25}), &flat, w, h);
assert_eq!(out, run("artistic.sponge", json!({"definition": 25}), &flat, w, h), "draws the same twice");
assert!(out.as_chunks::<4>().0.iter().zip(flat.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept");
// A flat colour breaks up into lighter and darker blobs of the same hue.
let top = &out[..w * 50 * 4];
assert!(spread(top) > 15.0, "{}", spread(top));
assert!(top.as_chunks::<4>().0.iter().all(|p| p[0] >= p[1] && p[1] >= p[2]), "red stays the strongest channel");
// Definition 0 leaves a flat colour as it is; more definition, more contrast.
let none = run("artistic.sponge", json!({"definition": 0}), &flat, w, h);
assert!(spread(&none[..w * 50 * 4]) < 1.0);
let some = run("artistic.sponge", json!({"definition": 8}), &flat, w, h);
assert!(spread(&some[..w * 50 * 4]) < spread(top));
survives("artistic.sponge", &[json!({}), json!({"brushSize": 1e308, "definition": 1e308, "smoothness": 1e308}), json!({"brushSize": "NaN"})]);
}
#[test]
fn watercolor_simplifies_detail_and_saturates_edges() {
assert_eq!(fx("artistic.watercolor", json!({})), PixelFx::Watercolor { detail: 9.0, shadow: 1.0, texture: 1 });
assert_eq!(
fx("artistic.watercolor", json!({"brushDetail": 99, "shadowIntensity": -1, "texture": 2.6})),
PixelFx::Watercolor { detail: 14.0, shadow: 0.0, texture: 3 }
);
// Noisy grey on the left, a step from light red to dark blue on the right, a transparent row.
let (w, h) = (64, 48);
let src = image(w, h, |x, y| {
if y >= 44 {
return [0; 4];
}
if x < 32 {
grey(if (x * 7 + y * 13) % 5 < 2 { 90 } else { 150 })
} else if x < 48 {
[200, 120, 120, 255]
} else {
[60, 70, 110, 255]
}
});
let few = run("artistic.watercolor", json!({"brushDetail": 1}), &src, w, h);
let many = run("artistic.watercolor", json!({"brushDetail": 14}), &src, w, h);
assert_eq!(few, run("artistic.watercolor", json!({"brushDetail": 1}), &src, w, h), "draws the same twice");
assert!(few.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]), "coverage kept, nothing painted outside");
assert!(roughness(&few, w, 4, 28) < 0.5 * roughness(&src, w, 4, 28), "less detail, simpler");
assert!(roughness(&few, w, 4, 28) < roughness(&many, w, 4, 28));
// Colour is saturated where the tones change, and shadows deepen with Shadow Intensity.
let chroma = |d: &[u8], x: usize, y: usize| {
let i = (y * w + x) * 4;
f64::from(d[i].max(d[i + 1]).max(d[i + 2])) - f64::from(d[i].min(d[i + 1]).min(d[i + 2]))
};
assert!(chroma(&few, 47, 20) > chroma(&src, 47, 20) || chroma(&few, 48, 20) > chroma(&src, 48, 20));
assert!(mean(&run("artistic.watercolor", json!({"shadowIntensity": 10}), &src, w, h), w, 4, 28) < mean(&few, w, 4, 28));
// Texture adds paper grain to a flat colour.
let flat = image(w, h, |_, _| grey(128));
let (t1, t3) = (run("artistic.watercolor", json!({"texture": 1}), &flat, w, h), run("artistic.watercolor", json!({"texture": 3}), &flat, w, h));
assert!(spread(&t3) > spread(&t1) && spread(&t1) > 0.2);
survives(
"artistic.watercolor",
&[json!({}), json!({"brushDetail": -1e308, "shadowIntensity": 1e308, "texture": 1e308}), json!({"brushDetail": "NaN"})],
);
}
+181
View File
@@ -0,0 +1,181 @@
//! Sketch › Bas Relief, Charcoal, Photocopy and Stamp.
use serde_json::json;
use vectorcraft_doc::Effect;
use vectorcraft_geom::{Affine, Point, Rect};
use super::*;
use crate::{RasterFx, raster_effects};
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
fn space(w: usize, h: usize) -> PixelSpace {
let (w, h) = (w as f64, h as f64);
PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
}
}
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| f(x, y)).collect()
}
fn fx(id: &str, p: serde_json::Value) -> PixelFx {
let e = Effect { id: id.into(), params: p, visible: true };
match raster_effects(&[e]).first() {
Some(RasterFx::Pixel(p)) => p.clone(),
other => panic!("{id}: {other:?}"),
}
}
fn run(id: &str, p: serde_json::Value, src: &[u8], w: usize, h: usize) -> Vec<u8> {
let mut out = src.to_vec();
fx(id, p).apply(&mut out, w, h, &space(w, h));
out
}
fn grey(v: u8) -> [u8; 4] {
[v, v, v, 255]
}
/// Channel 0 of pixel (`x`, `y`).
fn at(d: &[u8], w: usize, x: usize, y: usize) -> u8 {
d[(y * w + x) * 4]
}
/// How many pixels of channel 0 are darker than 128.
fn black(d: &[u8]) -> usize {
d.as_chunks::<4>().0.iter().filter(|p| p[0] < 128).count()
}
/// No panic and the coverage kept on tiny, huge and non-finite spaces and parameters.
fn survives(id: &str, params: &[serde_json::Value]) {
let (w, h) = (9, 7);
let src = image(w, h, |x, y| [(x * 25) as u8, (y * 30) as u8, 90, if x == 0 { 0 } else { 255 }]);
let tiny = PixelSpace { px: 1e-9, ..space(w, h) };
let huge = PixelSpace { px: 1e9, center: Point::new(f64::MAX, -1e300), ..space(w, h) };
let nan = PixelSpace { px: f64::NAN, ..space(w, h) };
for p in params {
for s in [tiny, huge, nan, space(w, h)] {
let mut out = src.clone();
fx(id, p.clone()).apply(&mut out, w, h, &s);
assert_eq!(out.len(), src.len());
let alphas = |d: &[u8]| d.as_chunks::<4>().0.iter().map(|p| p[3]).collect::<Vec<_>>();
if s.px == 1.0 {
assert_eq!(alphas(&out), alphas(&src), "{id}: coverage kept");
}
}
}
// A size that doesn't match, and an empty raster, do nothing.
let mut odd = vec![1u8; 10];
fx(id, json!({})).apply(&mut odd, 3, 3, &space(3, 3));
assert_eq!(odd, vec![1u8; 10]);
fx(id, json!({})).apply(&mut [], 0, 0, &space(1, 1));
}
#[test]
fn photocopy_copies_the_edges_of_dark_areas() {
assert_eq!(fx("sketch.photocopy", json!({})), PixelFx::Photocopy { detail: 7.0, darkness: 25.0 });
assert_eq!(fx("sketch.photocopy", json!({"detail": 1e308, "darkness": -5})), PixelFx::Photocopy { detail: 24.0, darkness: 1.0 });
let (w, h) = (64, 16);
let src = image(w, h, |x, _| grey(if x < 32 { 30 } else { 230 }));
let out = run("sketch.photocopy", json!({"detail": 20}), &src, w, h);
// Black just on the dark side of the edge; white in the dark area's middle and in the light.
assert!(at(&out, w, 30, 8) < 64, "edge {}", at(&out, w, 30, 8));
assert!(at(&out, w, 6, 8) > 192, "dark area {}", at(&out, w, 6, 8));
assert!(at(&out, w, 50, 8) > 192, "light area {}", at(&out, w, 50, 8));
assert_eq!(out, run("sketch.photocopy", json!({"detail": 20}), &src, w, h), "deterministic");
// A higher Darkness copies more.
let more = run("sketch.photocopy", json!({"detail": 20, "darkness": 50}), &src, w, h);
let less = run("sketch.photocopy", json!({"detail": 20, "darkness": 1}), &src, w, h);
assert!(black(&more) >= black(&less));
survives("sketch.photocopy", &[json!({}), json!({"detail": 1e308, "darkness": "NaN"}), json!({"detail": -1e308, "darkness": [1]})]);
}
#[test]
fn stamp_prints_solid_black_and_white() {
assert_eq!(fx("sketch.stamp", json!({})), PixelFx::Stamp { balance: 25.0, smoothness: 5.0 });
assert_eq!(fx("sketch.stamp", json!({"lightDarkBalance": 1e308, "smoothness": -1})), PixelFx::Stamp { balance: 50.0, smoothness: 1.0 });
let (w, h) = (64, 8);
let src = image(w, h, |x, _| grey((x * 4) as u8));
let few = run("sketch.stamp", json!({"lightDarkBalance": 0}), &src, w, h);
let many = run("sketch.stamp", json!({"lightDarkBalance": 50}), &src, w, h);
assert!(black(&many) > black(&few), "{} vs {}", black(&many), black(&few));
// Pure black and white, but for the one pixel wide edges.
let mixed = many.as_chunks::<4>().0.iter().filter(|p| p[0] != 0 && p[0] != 255).count();
assert!(mixed * 10 < many.len() / 4, "{mixed} grey pixels");
assert_eq!(at(&many, w, 0, 4), 0);
assert_eq!(at(&many, w, 63, 4), 255);
survives(
"sketch.stamp",
&[json!({}), json!({"lightDarkBalance": "NaN", "smoothness": 1e308}), json!({"lightDarkBalance": -1e308, "smoothness": null})],
);
}
#[test]
fn charcoal_draws_bold_edges_and_sketches_the_midtones() {
assert_eq!(fx("sketch.charcoal", json!({})), PixelFx::Charcoal { thickness: 1.0, detail: 1.0, balance: 50.0 });
assert_eq!(
fx("sketch.charcoal", json!({"thickness": 1e308, "detail": -5, "lightDarkBalance": "x"})),
PixelFx::Charcoal { thickness: 7.0, detail: 0.0, balance: 50.0 }
);
let (w, h) = (64, 48);
// Black on the left, white on the right: a bold line at the edge, nothing in the white.
let step = image(w, h, |x, _| grey(if x < 32 { 0 } else { 255 }));
let out = run("sketch.charcoal", json!({}), &step, w, h);
assert!((28..36).any(|x| at(&out, w, x, 24) < 64), "edge");
assert!((48..64).all(|x| at(&out, w, x, 24) == 255), "white stays white");
// A midtone is sketched in strokes, more of it the more the balance reaches into the lights.
let mid = image(w, h, |_, _| grey(128));
let sketched = run("sketch.charcoal", json!({}), &mid, w, h);
let (n, total) = (black(&sketched), w * h);
assert!(n > total / 50 && n < total * 9 / 10, "{n} of {total}");
let darker = run("sketch.charcoal", json!({"lightDarkBalance": 100}), &mid, w, h);
let lighter = run("sketch.charcoal", json!({"lightDarkBalance": 0}), &mid, w, h);
assert!(black(&darker) > black(&lighter));
assert_eq!(sketched, run("sketch.charcoal", json!({}), &mid, w, h), "deterministic");
let white = image(w, h, |_, _| grey(255));
assert_eq!(black(&run("sketch.charcoal", json!({"lightDarkBalance": 0}), &white, w, h)), 0);
survives(
"sketch.charcoal",
&[json!({}), json!({"thickness": 1e308, "detail": "NaN", "lightDarkBalance": [1]}), json!({"thickness": -1e308, "detail": 1e308})],
);
}
#[test]
fn bas_relief_lights_the_surfaces_facing_the_light() {
assert_eq!(fx("sketch.basRelief", json!({})), PixelFx::BasRelief { detail: 13.0, smoothness: 3.0, light: Light::Bottom });
assert_eq!(
fx("sketch.basRelief", json!({"detail": 0, "smoothness": 1e308, "lightDirection": "Top"})),
PixelFx::BasRelief { detail: 1.0, smoothness: 15.0, light: Light::Top }
);
// Dark above, light below: the edge is lit from one side and shaded from the other.
let (w, h) = (16, 48);
let src = image(w, h, |_, y| grey(if y < 24 { 20 } else { 235 }));
let below = run("sketch.basRelief", json!({"lightDirection": "bottom"}), &src, w, h);
let above = run("sketch.basRelief", json!({"lightDirection": "top"}), &src, w, h);
let edge = (0..h).map(|y| (y, i32::from(at(&below, w, 8, y)) - i32::from(at(&above, w, 8, y)))).max_by_key(|(_, d)| d.abs());
assert!(edge.is_some_and(|(_, d)| d.abs() > 100), "{edge:?}");
// Flat areas are grey.
for y in [2, 45] {
let v = at(&below, w, 8, y);
assert!((90..190).contains(&v), "flat {v}");
}
survives(
"sketch.basRelief",
&[json!({}), json!({"detail": "NaN", "smoothness": 1e308, "lightDirection": 3}), json!({"detail": -1e308, "smoothness": null})],
);
}
#[test]
fn sketch_filters_reach_beyond_nothing_they_do_not_paint() {
for id in ["sketch.basRelief", "sketch.charcoal", "sketch.photocopy", "sketch.stamp"] {
let f = fx(id, json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage");
assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach");
}
}
+2 -2
View File
@@ -128,7 +128,7 @@ impl Light {
}
/// The unit vector towards the light (document axes, y down).
fn toward(self) -> Vec2 {
pub(super) fn toward(self) -> Vec2 {
let d = FRAC_1_SQRT_2;
match self {
Light::Bottom => Vec2::new(0.0, 1.0),
@@ -269,7 +269,7 @@ impl Tones {
}
/// The tone at pixel (`x`, `y`) and its slope per document unit along the document's axes.
fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
pub(super) fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
let gx = 0.5 * (self.get(x + 1, y) - self.get(x.saturating_sub(1), y));
let gy = 0.5 * (self.get(x, y + 1) - self.get(x, y.saturating_sub(1)));
// Per pixel → per document unit: through the transpose of the document → pixel map.
+1 -1
View File
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
+1 -1
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
use vectorcraft_geom::Rect;
pub use flavours::{BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub(crate) use resources::SwatchChoices;
use super::*;
+48 -8
View File
@@ -30,6 +30,10 @@ pub const EMF: &str = "image/emf";
/// An EPS (or other PostScript) picture: what drawing and typesetting apps copy as vector art
/// beside, or instead of, a PDF (#1499).
pub const EPS: &str = "application/postscript";
/// A `.ai` or `.ait` file whose bytes are a PDF, copied in a file manager ([`file_flavour`]): Paste
/// reads it as `document.open` does, from its editing data. The type is VectorCraft's own: no
/// system clipboard carries it, and Paste asks for [`PDF`] to get it.
pub const AI: &str = "application/x-ai";
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
/// (word processors offer a picture of copied text too).
pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP];
@@ -71,8 +75,9 @@ pub const FILE_HEAD: usize = 4096;
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file pastes as PDF when its bytes are a
/// PDF and as EPS when they are PostScript. The file's bytes are that flavour's data.
/// [`FILE_HEAD`] bytes and its name. A `.ai` or `.ait` file is [`AI`] when its bytes are a PDF (it
/// pastes when PDF is asked for) and EPS when they are PostScript. The file's bytes are that
/// flavour's data.
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
let f = fileio::detect(name, head)?;
let (asked, mime) = match f.id {
@@ -83,7 +88,7 @@ pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static
"pdf" => (PDF, PDF),
"ps" => (EPS, EPS),
"eps" => (EPS, EPS),
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, PDF),
"ai" | "ait" if head.starts_with(b"%PDF") => (PDF, AI),
"ai" | "ait" if vectorcraft_pdf::is_postscript(head) => (EPS, EPS),
"emf" | "wmf" => (EMF, EMF),
_ if f.raster => (BITMAP, f.mime),
@@ -122,6 +127,13 @@ pub struct Flavour {
const COPY_PNG_SIDE: f64 = 4096.0;
/// Most bytes of text `clipboard.importText` takes.
const MAX_TEXT: usize = 1 << 20;
/// Most bytes of images the SVG markup may embed and still go out as plain text. A paste reads the
/// clipboard's text first (eframe reads it on every paste key; on Linux and macOS it is the one
/// format the app writes, and checking that the clipboard still holds our copy reads it again), so
/// a placed photo in it made each paste move megabytes of base64 through the system clipboard on
/// the UI thread, and on a GNOME desktop the app stopped responding (#2213). A copy with more image
/// data than this offers its SVG under its own type and its PNG, not as text.
const COPY_TEXT_IMAGE_BYTES: usize = 1 << 20;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -157,7 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clipboard Formats",
[],
None,
"{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut",
"{} → {flavours: [mime…]} what Copy offers other apps for the current clipboard, best first, by the Clipboard Handling preferences: text/plain (a type-only copy's text, else the SVG markup with copyAsSvg when the images it embeds total at most 1 MiB), image/svg+xml (copyAsSvg), application/pdf (copyAsPdf), image/png (always; PDF and PNG only for objects with an area); the app publishes them on Copy and Cut",
always,
flavours
),
@@ -184,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load PDF into Clipboard",
[],
None,
"{dataBase64, page?: 1, password? (an encrypted PDF), center?: [x, y]} replace the clipboard with the objects of one PDF page (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
"{dataBase64, page?: 1, password? (an encrypted PDF), editingData?: false (true: the data is a .ai or .ait file, read as document.open reads it; when document.open would use its editing data, every object of that data comes in, each layer's objects in stacking order and a hidden layer's objects hidden; else page `page`), center?: [x, y]} replace the clipboard with the objects of one PDF page, or of a .ai file's editing data (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
has_doc,
import_pdf
),
@@ -202,7 +214,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load EPS into Clipboard",
[],
None,
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use; a hidden layer's objects hidden), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
has_doc,
import_eps
),
@@ -263,12 +275,17 @@ impl Clipboard {
fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
}
/// Bytes of the encoded images the objects show (what their SVG embeds as base64).
fn image_bytes(&self) -> usize {
self.images.values().fold(0usize, |sum, b| sum.saturating_add(b.bytes.len()))
}
/// The MIME types of [`Self::flavours`], without making them.
pub fn flavour_types(&self, svg: bool, pdf: bool) -> Vec<&'static str> {
if self.is_empty() {
return vec![];
}
let text = svg || self.nodes.iter().all(type_only);
let text = (svg && self.image_bytes() <= COPY_TEXT_IMAGE_BYTES) || self.nodes.iter().all(type_only);
// Art without an area (a lone guide-like line) has no page or picture.
let area = self.bounds().is_some_and(|b| b.width() > 0.0 && b.height() > 0.0);
[(TEXT, text), (SVG, svg), (PDF, pdf && area), (PNG, area)].into_iter().filter_map(|(m, on)| on.then_some(m)).collect()
@@ -452,8 +469,19 @@ fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
/// Replace the clipboard with the objects of the picture `bytes`, read as the file `name` is
/// opened (command `cmd`).
fn import_picture(s: &mut Session, p: &Value, cmd: &str, name: &str, bytes: &[u8]) -> Result<Value> {
let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
let loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
import_loaded(s, p, cmd, loaded)
}
/// Replace the clipboard with every object of `loaded`, a file read as `document.open` reads it
/// (command `cmd`). The layers are left out, and the objects of a hidden layer come in hidden.
fn import_loaded(s: &mut Session, p: &Value, cmd: &str, mut loaded: fileio::Loaded) -> Result<Value> {
loaded.doc.drop_edit_modes();
for layer in loaded.doc.layers.iter_mut().filter(|l| !l.visible) {
for object in std::sync::Arc::make_mut(layer).children_mut().into_iter().flatten() {
std::sync::Arc::make_mut(object).visible = false;
}
}
let clip = Clipboard::from_document(&loaded.doc);
if clip.is_empty() {
return Err(bad(cmd, "the picture has no objects"));
@@ -469,7 +497,19 @@ fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => 1,
Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(C, "page must be a whole number from 1"))? as usize,
};
let editing = match p.get("editingData").filter(|v| !v.is_null()) {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad(C, "editingData must be true or false"))?,
};
let opts = fileio::LoadOptions { password: str_param(p, "password").map(str::to_string), ..Default::default() };
// A copied .ai file pastes every object of its editing data, as document.open reads the file.
// When document.open would not use that copy, the file pastes its page, as a PDF does.
if editing
&& let Ok(loaded) = fileio::load_with("Clipboard.ai", &bytes, &opts)
&& (loaded.editing_data || loaded.restored)
{
return import_loaded(s, p, C, loaded);
}
let (mut doc, warnings) = fileio::page_document(&bytes, page - 1, &opts).map_err(|e| bad(C, e.to_string()))?;
doc.drop_edit_modes();
let clip = Clipboard::from_document(&doc);
+9 -8
View File
@@ -290,7 +290,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
for item in &l.appearance.items {
// Items that paint nothing go; a gradient that can't be expanded stays.
let art = gradient_fill(item).and_then(|(f, g)| {
let (fl, path, bounds) = fill_shape(l, f, &path, bounds)?;
let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?;
gradient_art(d, &fl, &path, bounds, f, g, to)
});
let Some(art) = art else {
@@ -363,15 +363,16 @@ fn reshaped(l: &Node) -> Option<Node> {
Some(m)
}
/// Path `l` (outline `path`, box `bounds`) as fill `f` paints it on the canvas, with its outline
/// and box: when the fill has visible geometry effects, `l` with the outline they make of `path`
/// as a plain path, and that outline's box.
fn fill_shape(l: &Node, f: &FillLayer, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
/// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its
/// outline and box: when the fill has visible geometry effects, `l` with the outline they make of
/// `path` as a plain path, and that outline's box.
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
if !has_geometry(&f.effects) {
if !has_geometry(item.effects()) {
return Some((l.clone(), path.clone(), bounds));
}
let outline = apply_geometry_with(&f.effects, path, bounds, &GeomContext::of(l));
// In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on.
let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item));
let b = outline.bounds()?;
Some((with_path(l, outline.clone()), outline, b))
}
@@ -432,7 +433,7 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option<Node> {
for item in &l.appearance.items {
let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf));
let tiles = fill.and_then(|(f, def, xf)| {
let (fl, _, bounds) = fill_shape(l, f, &path, bounds)?;
let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?;
let t = pattern_tiles(d, &def, xf, bounds);
(!t.is_empty()).then_some((f, fl, t))
});
+29
View File
@@ -411,6 +411,9 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
/// [`encode_all`] of `doc` as it is.
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let f = super::writable(C, Some(format), None)?;
if let Some(plugin) = vectorcraft_plugins::export::by_format(f.id) {
return plugin_file(doc, &plugin, f, p);
}
let failed = vectorcraft_render::decode_failures();
let mut enc = encode_files(doc, f, p)?;
// Baking, flattening and drawing stop early when cancelled and leave a truncated result.
@@ -432,6 +435,32 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
Ok(enc)
}
/// The file exporter plug-in `plugin` writes of `doc` in its format `f`
/// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON
/// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else
/// one named after the document) with that path's extension when the plug-in lists it, else its
/// first. Its warnings come back with the file.
fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result<Encoded> {
use vectorcraft_plugins::{Error, export};
let doc = &*doc.without_edit_modes();
let file = vectorcraft_format::save_with(doc, &vectorcraft_format::SaveOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
let path = p.get("path").and_then(Value::as_str);
let listed = path.map(super::extension).and_then(|e| f.extensions.iter().copied().find(|x| *x == e));
let extension = listed.or(f.extensions.first().copied()).unwrap_or_default();
let name = super::file_stem(path.unwrap_or(&super::default_name(doc, extension)));
let params = p.get("params").cloned().unwrap_or(Value::Null);
let target = export::Target { extension, name: &name };
let out = export::run(plugin, vectorcraft_format::json_part(&file), &params, target).map_err(|e| match e {
Error::Cancelled => EngineError::Cancelled,
Error::Params(why) => bad(C, why),
e => EngineError::Other(e.to_string()),
})?;
// The plug-in's output, so reported as its other output errors are.
let invalid = || EngineError::Other(Error::Output("`dataBase64` is not valid base64".into()).to_string());
let bytes = vectorcraft_format::base64_decode(&out.data_base64).ok_or_else(invalid)?;
Ok(Encoded { warnings: out.warnings, ..Encoded::one(bytes) })
}
/// [`encode_all`] without the font substitution warning.
fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let doc = &*doc.without_edit_modes();
+20 -9
View File
@@ -26,6 +26,9 @@ pub struct Loaded {
/// Only a stand-in picture of the file (an Affinity document whose native data couldn't be
/// read): Open shows it with its warning; Place, templates and libraries refuse it.
pub preview_only: bool,
/// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art
/// that it carries (its layers), not from its PDF part alone.
pub editing_data: bool,
}
impl Loaded {
@@ -156,17 +159,17 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
/// of its art, which it carries: its layers and hidden objects as they were, and the art outside
/// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read.
/// Anything else as it was. `unit`: the size in points of a unit of the PDF part's user space (its
/// first page's `UserUnit`).
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>, unit: f64) -> (Document, Vec<String>) {
/// first page's `UserUnit`) → (document, warnings, whether its layers were read).
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>, unit: f64) -> (Document, Vec<String>, bool) {
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
return (doc, warnings);
return (doc, warnings, false);
}
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings, false) };
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data, unit);
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
if !read {
return (doc, warnings);
return (doc, warnings, false);
}
// The layers' text objects hold type and the stroked paths around it, which can include stroked
// glyphs the PDF part kept as outlines: without any such glyph in the document, the warnings on
@@ -177,7 +180,7 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
// The layers hold the art that the PDF part leaves out or, in a PDF, that the import clipped
// to the page.
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE && w != vectorcraft_pdf::PAST_PAGE_NOTE);
(doc, warnings)
(doc, warnings, true)
}
/// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page) from the editor's
@@ -209,6 +212,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let title = file_name(name);
let mut converted = false;
let mut preview_only = false;
let mut editing_data = false;
let (mut doc, mut warnings, restored) = match format.id {
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
@@ -243,13 +247,20 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// Pages of text alone: the editing copy alone where it reads, as for a file
// saved without its PDF part (#1387: else the page, which is the art).
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)))
let (doc, warnings, read) = match alone {
Some((doc, warnings)) => (doc, warnings, true),
None => through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit),
};
editing_data = read;
Ok((doc, warnings))
}
// 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() => {
let whole = LoadOptions { pages: None, ..opts.clone() };
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }).unwrap_or(Err(e))
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts })
.unwrap_or(Err(e))
.inspect(|_| editing_data = true)
}
Err(e) => Err(e),
})?
@@ -295,7 +306,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
doc.title = title;
}
warnings.extend(doc.images.values().filter_map(|b| too_large_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only })
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
}
/// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's
+54 -7
View File
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -198,7 +198,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File Formats",
[],
None,
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}",
"{} → {formats: [{id, label, extensions, mime, read, write, raster, options: {name: {type, default, description}}, plugin?: the id of the exporter plug-in that adds the format (its id is plugin.<plug-in id>; see plugin.list)}], readable: [id…], writable: [id…], openExtensions: [ext…], unsupported: [{id, label, extensions, hint}] (formats asked for that can be neither opened nor written, such as DWG and PICT, with what to use instead)}",
always,
formats
),
@@ -713,7 +713,8 @@ pub const UNASSOCIATED_EXTS: &[&str] = &["psd", "psb", "epsf", "ps", "tga", "vda
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
pub fn export_extensions() -> Vec<&'static str> {
let mut v: Vec<&'static str> = Vec::new();
for e in FORMATS.iter().filter(|f| f.write).filter_map(|f| f.extensions.first()) {
let added = plugin_formats();
for e in FORMATS.iter().chain(added.iter().copied()).filter(|f| f.write).filter_map(|f| f.extensions.first()) {
if !v.contains(e) {
v.push(e);
}
@@ -800,10 +801,47 @@ pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Word", DOCX_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in
/// one, else one an exporter plug-in adds ([`plugin_formats`]). A built-in format keeps its
/// extension, so an exporter that lists it too is found by its format id.
pub fn format(id_or_ext: &str) -> Option<&'static Format> {
let k = id_or_ext.trim_start_matches('.').to_ascii_lowercase();
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str())))
FORMATS.iter().find(|f| f.id == k).or_else(|| FORMATS.iter().find(|f| f.extensions.contains(&k.as_str()))).or_else(|| {
let added = plugin_formats();
added.iter().find(|f| f.id.eq_ignore_ascii_case(&k)).or_else(|| added.iter().find(|f| f.extensions.contains(&k.as_str()))).copied()
})
}
/// The formats the installed exporter plug-ins add ([`vectorcraft_plugins::export`]), by plug-in
/// id: write only, with the plug-in's name as the label and its extensions. Each is made once for
/// each installation of its plug-in, its strings interned, so formats live as long as the catalogues
/// that hold them.
pub fn plugin_formats() -> Vec<&'static Format> {
use vectorcraft_plugins::{export, registry};
static MADE: std::sync::Mutex<Vec<(u64, &'static Format)>> = std::sync::Mutex::new(Vec::new());
let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner());
export::installed()
.iter()
.map(|p| {
if let Some((_, f)) = made.iter().find(|(serial, _)| *serial == p.serial()) {
return *f;
}
let m = p.manifest();
let extensions: Vec<&'static str> = m.extensions.iter().map(|e| registry::intern(e)).collect();
let f: &'static Format = Box::leak(Box::new(Format {
id: registry::intern(&export::format_id(&m.id)),
label: registry::intern(&m.name),
extensions: Box::leak(extensions.into_boxed_slice()),
mime: "application/octet-stream",
read: false,
write: true,
raster: false,
options: &[],
}));
made.push((p.serial(), f));
f
})
.collect()
}
/// The lower-case extension of a file name or path (empty when it has none).
@@ -858,9 +896,18 @@ fn writable(cmd: &str, format_param: Option<&str>, path: Option<&str>) -> Result
}
fn formats(_: &mut Session, _: &Value) -> Result<Value> {
let ids = |pick: fn(&Format) -> bool| FORMATS.iter().filter(|f| pick(f)).map(|f| f.id).collect::<Vec<_>>();
let added = plugin_formats();
let all = || FORMATS.iter().chain(added.iter().copied());
let ids = |pick: fn(&Format) -> bool| all().filter(|f| pick(f)).map(|f| f.id).collect::<Vec<_>>();
let listed = |f: &Format| {
let mut v = f.to_json();
if let Some(id) = vectorcraft_plugins::export::plugin_id(f.id) {
v["plugin"] = json!(id);
}
v
};
Ok(json!({
"formats": FORMATS.iter().map(Format::to_json).collect::<Vec<_>>(),
"formats": all().map(listed).collect::<Vec<_>>(),
"readable": ids(|f| f.read),
"writable": ids(|f| f.write),
"openExtensions": OPEN_EXTS,
@@ -103,6 +103,87 @@ fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
assert_eq!(found, [[[255, 0, 0], [0, 255, 0], [0, 0, 255]]], "{r}");
}
/// A page of a square and a line of type, as a `.ai`'s PDF part draws them.
const SQUARE_AND_TYPE: &str = "0 g 10 10 40 40 re f BT /F1 12 Tf 100 50 Td (Hi) Tj ET";
/// `file` loaded with `clipboard.importPdf` (`editingData` as given) and pasted → the command's
/// reply and the objects the paste selected, in stacking order.
fn paste(s: &mut Session, file: &[u8], editing_data: bool) -> (Value, Vec<Node>) {
let p = json!({"dataBase64": vectorcraft_format::base64_encode(file), "editingData": editing_data});
let r = s.execute("clipboard.importPdf", &p).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let st = s.doc().unwrap();
(r, st.selection.in_paint_order(&st.doc).into_iter().filter_map(|id| st.doc.node(id).cloned()).collect())
}
/// Is any of `nodes` (or an object inside one) type?
fn has_type(nodes: &[Node]) -> bool {
let mut found = false;
nodes.iter().for_each(|n| n.walk(&mut |c| found |= matches!(c.kind, NodeKind::Text(_))));
found
}
/// A `.ai` copied in a file manager pastes the objects of its editing data, as `document.open`
/// reads it: both layers' objects in their stacking order, into the active layer, with their names
/// and hidden members, and its type as type. Without `editingData`, as for a PDF, its page pastes:
/// the shown layer's art without names.
#[test]
fn a_copied_ai_file_pastes_from_its_editing_data() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let file = ai::ai(&compressed(&ai::sample_data()), ai::page_pdf());
let (r, pasted) = paste(&mut s, &file, true);
assert_eq!(r["count"], 7, "{r}");
let pasted_names: Vec<String> = pasted.iter().map(|n| n.name.clone().unwrap_or_default()).collect();
assert_eq!(pasted_names, ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""]);
let pair = pasted[0].children().unwrap();
assert!(pair[0].visible && !pair[1].visible, "the hidden member stays hidden");
assert!(matches!(pasted[5].kind, NodeKind::Layer { .. }) && pasted[5].locked, "the sublayer comes as it is");
assert_eq!(pasted.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, true, true, false, false, false, false], "the hidden layer's objects");
let layers = &s.doc().unwrap().doc.layers;
assert_eq!(layers.len(), 1, "no layer is added");
assert!(pasted.iter().all(|n| layers[0].children().unwrap().iter().any(|c| c.id == n.id)), "into the active layer");
// The same editing data in an EPS: the same objects, the hidden layer's objects hidden.
let eps = ai::eps(&ai::sample_data(), ai::page_ps());
s.execute("clipboard.importEps", &json!({"dataBase64": vectorcraft_format::base64_encode(&eps)})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let st = s.doc().unwrap();
let shown = |nodes: Vec<&Node>| nodes.iter().map(|n| (n.name.clone().unwrap_or_default(), n.visible)).collect::<Vec<_>>();
assert_eq!(shown(st.selection.in_paint_order(&st.doc).into_iter().filter_map(|id| st.doc.node(id)).collect()), shown(pasted.iter().collect()));
let (r, page) = paste(&mut s, &file, false);
assert!(page.iter().all(|n| n.name.is_none()), "{r}");
// A page of text only, or only a placeholder (saved without PDF compatibility): the editing
// data's objects all the same.
for (page, boxed) in [(TEXT_ART, "/ArtBox [0 0 200 100]"), (PLACEHOLDER, "")] {
let (r, pasted) = paste(&mut s, &ai::ai_with(&compressed(&ai::sample_data()), page, boxed), true);
assert_eq!(pasted.first().and_then(|n| n.name.as_deref()), Some("Pair"), "{page}: {r}");
assert_eq!(r["count"], 7, "{page}: {r}");
}
// Type: a text object from the editing data, outlines from the page.
let data = ai::editing_data(
200.0,
100.0,
&ai::layer("Art", &format!("0 g\n{}f\n/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n", ai::rect(10.0, 10.0, 50.0, 50.0))),
);
let typed = ai::ai(&compressed(data.as_bytes()), SQUARE_AND_TYPE);
// (The page's type comes in as type too now that PDF import keeps it, so only this side is told apart.)
let (r, pasted) = paste(&mut s, &typed, true);
assert!(has_type(&pasted), "{r}: {pasted:?}");
// A `.ai` VectorCraft wrote: the document it carries, its hidden square too.
let mut other = Session::new();
other.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
for x in [10, 100] {
other.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 40, "height": 40})).unwrap();
}
other.execute("object.hide", &json!({})).unwrap();
let saved = other.execute("document.export", &json!({"format": "ai"})).unwrap();
let saved = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
let (r, pasted) = paste(&mut s, &saved, true);
assert_eq!(pasted.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, false], "{r}");
let (r, page) = paste(&mut s, &saved, false);
assert!(page.iter().all(|n| n.visible), "{r}");
}
/// A PDF saved with the editing data opens from it, as a `.ai` does (#868).
#[test]
fn a_pdf_saved_with_the_editing_data_opens_from_it() {
@@ -173,6 +254,29 @@ fn editing_data_that_cant_be_read_keeps_the_pdf_parts_notes() {
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE, unread]), "{r}");
}
/// A copied `.ai` whose editing data can't be read, or isn't there, pastes its page as a PDF
/// does: the same objects as without `editingData`.
#[test]
fn a_copied_ai_file_whose_editing_data_cant_be_read_pastes_its_page() {
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
for (what, private) in
[("damaged", b"%AI12_CompressedData not zlib".to_vec()), ("unread layers", compressed(symbols.as_bytes())), ("none", vec![])]
{
let file = ai::ai(&private, SQUARE_AND_TYPE);
let (r, pasted) = paste(&mut s, &file, true);
let (want, page) = paste(&mut s, &file, false);
assert_eq!((&r["count"], &r["warnings"]), (&want["count"], &want["warnings"]), "{what}");
let kinds = |nodes: &[Node]| nodes.iter().map(|n| std::mem::discriminant(&n.kind)).collect::<Vec<_>>();
assert_eq!(kinds(&pasted), kinds(&page), "{what}");
assert!(!pasted.is_empty(), "{what}: {r}");
}
// Editing data that reads and draws as the page does: its objects, not the page's.
let (r, _) = paste(&mut s, &ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), true);
assert_eq!(r["count"], 7, "{r}");
}
#[test]
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
+79 -44
View File
@@ -143,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Artboard Options…",
["Window", "Artboards"],
None,
"{index, name?, x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
"{index | artboards: [{index, name?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}",
has_doc,
artboard_set
),
@@ -834,59 +834,94 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// One artboard's part of `artboard.setProps`: its index, new rectangle and name, how far it moved,
/// whether it was resized, the map its art and guides take when they go with it (`fit` when it
/// scales them), and that art.
struct ArtboardEdit<'a> {
index: usize,
now: Rect,
name: Option<&'a str>,
delta: vectorcraft_geom::Vec2,
resized: bool,
fit: Option<Affine>,
transform: Option<Affine>,
art: Vec<NodeId>,
}
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?;
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let now = rect_from(p, was);
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
// Scale Artwork with Artboard: resized, it takes the art fully inside it (as Move Artwork with
// Artboard gathers it) and its guides from the old rectangle onto the new one.
let scale_art = bool_or(p, "scaleArt", false);
let fit = if scale_art && resized { rect_map(was, now) } else { None };
// Position fields honor Move Artwork independently of Scale Artwork. A resize still
// leaves the art alone unless scaling was requested. Fit-to-art callers keep the default.
let delta = now.origin() - was.origin();
let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO;
let transform = fit.or_else(|| moving.then(|| Affine::translate(delta)));
let art = if transform.is_some() {
// Locked and hidden art too? Noted in the journal, so a replay transforms the same objects
// whatever the preference is then.
let all = bool_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard);
s.note_journal("lockedAndHidden", json!(all));
s.doc()?.doc.art_on_artboard(was, all)
} else {
vec![]
};
let mut sc = match fit {
Some(xf) if Scaling::factor(xf).is_some() => super::object::scaling(s, p),
_ => Scaling::default(),
};
sc.patterns = super::object::transform_patterns(s, p, &art)?;
s.edit("Artboard Options", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = now;
if let Some(n) = str_param(p, "name").map(str::trim) {
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
a.name = if n.is_empty() { format!("Artboard {}", i + 1) } else { n.to_string() };
// One artboard (`index` and its fields), or several (`artboards`, each with its own fields).
let entries: Vec<&Value> = match p.get("artboards") {
Some(list) => {
list.as_array().filter(|l| !l.is_empty()).ok_or_else(|| bad("artboard.setProps", "artboards must be a non-empty list"))?.iter().collect()
}
let id = a.id;
if let Some(xf) = transform {
d.map_artboard_guides(id, xf);
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(xf, sc);
None => vec![p],
};
// Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move
// Artwork with Artboard gathers it) and its guides from the old rectangle onto the new one.
let scale_art = bool_or(p, "scaleArt", false);
// Locked and hidden art too?
let all = bool_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard);
let d = &s.doc()?.doc;
// Each artboard's art is the art inside it before any of them changes; an object inside
// several of them goes with the first one listed that moves or scales its art.
let mut taken = HashSet::new();
let mut edits: Vec<ArtboardEdit> = Vec::with_capacity(entries.len());
for q in entries {
let index = super::panelcmds::artboard_index(d, q, "artboard.setProps")?;
if edits.iter().any(|e| e.index == index) {
return Err(bad("artboard.setProps", "an artboard is listed twice"));
}
let was = d.artboards.get(index).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let now = rect_from(q, was);
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
let fit = if scale_art && resized { rect_map(was, now) } else { None };
// Position fields honor Move Artwork independently of Scale Artwork. A resize still
// leaves the art alone unless scaling was requested. Fit-to-art callers keep the default.
let delta = now.origin() - was.origin();
let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO;
let transform = fit.or_else(|| moving.then(|| Affine::translate(delta)));
let art = if transform.is_some() { d.art_on_artboard(was, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] };
edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), delta, resized, fit, transform, art });
}
// Noted in the journal when art goes along, so a replay transforms the same objects whatever
// the preference is then.
if edits.iter().any(|e| e.transform.is_some()) {
s.note_journal("lockedAndHidden", json!(all));
}
let scaling =
if edits.iter().any(|e| e.fit.is_some_and(|xf| Scaling::factor(xf).is_some())) { super::object::scaling(s, p) } else { Scaling::default() };
let art: Vec<NodeId> = edits.iter().flat_map(|e| e.art.iter().copied()).collect();
let patterns = super::object::transform_patterns(s, p, &art)?;
s.edit("Artboard Options", |d, _| {
for e in &edits {
let a = d.artboards.get_mut(e.index).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = e.now;
if let Some(n) = e.name {
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
a.name = if n.is_empty() { format!("Artboard {}", e.index + 1) } else { n.to_string() };
}
let id = a.id;
if let Some(xf) = e.transform {
let mut sc = if e.fit.is_some_and(|xf| Scaling::factor(xf).is_some()) { scaling } else { Scaling::default() };
sc.patterns = patterns;
d.map_artboard_guides(id, xf);
for id in &e.art {
if let Some(n) = d.node_mut(*id) {
n.transform(xf, sc);
}
}
} else if !e.resized {
// Moved (not resized), it takes its guides along.
d.move_artboard_guides(id, e.delta);
}
} else if !resized {
// Moved (not resized), it takes its guides along.
d.move_artboard_guides(id, delta);
}
Ok(())
})?;
if !scale_art {
return ok();
}
Ok(json!({ "scaled": if fit.is_some() { art.iter().map(|i| i.0).collect::<Vec<_>>() } else { vec![] } }))
let scaled: Vec<_> = edits.iter().filter(|e| e.fit.is_some()).flat_map(|e| e.art.iter().map(|i| i.0)).collect();
Ok(json!({ "scaled": scaled }))
}
/// The map taking artboard rectangle `from` onto `to`, each side scaled by its own ratio. None
+66 -4
View File
@@ -5,7 +5,8 @@
//! one copies the selection as Copy does, and placing it pastes a copy as Paste does (its images,
//! symbols, patterns and swatches come along; a swatch name the document gives another colour is
//! merged into the document's). Items can be put in user-named groups, and a library can be
//! exported to a `.vclibrary` file and imported from one (#926).
//! exported to a `.vclibrary` file and imported from one (#926). Items and groups are kept in the
//! order the Libraries panel shows them, which dragging them there changes.
use std::collections::{HashMap, HashSet};
@@ -281,6 +282,25 @@ impl Library {
}
}
/// Move item `r` next to `to`, an item of its kind: just before it, or just after it when
/// `after`, in their kind's list (the order the panel shows them in).
fn place_next_to(&mut self, r: &ItemRef, to: &ItemRef, after: bool) {
fn shift<T>(list: &mut Vec<T>, key: impl Fn(&T) -> &str, from: &str, to: &str, after: bool) {
let Some(i) = list.iter().position(|x| key(x) == from) else { return };
let item = list.remove(i);
let j = list.iter().position(|x| key(x) == to).map_or(list.len(), |j| j + usize::from(after));
list.insert(j.min(list.len()), item);
}
let (from, to_key) = (r.item.as_str(), to.item.as_str());
match r.kind {
ItemKind::Color => shift(&mut self.colors, |c| c.name.as_str(), from, to_key, after),
ItemKind::CharStyle => shift(&mut self.char_styles, |c| c.name.as_str(), from, to_key, after),
ItemKind::ParaStyle => shift(&mut self.para_styles, |c| c.name.as_str(), from, to_key, after),
ItemKind::Graphic => shift(&mut self.graphics, |g| g.id.as_str(), from, to_key, after),
ItemKind::Unknown => {}
}
}
/// Make a library read from a file (hand-edited, imported) safe and consistent: at most
/// [`MAX_ITEMS`] items of each kind and [`MAX_GROUPS`] groups, names clipped to [`MAX_NAME`]
/// characters and unique within their kind (a repeated one gets a number), sizes finite, a
@@ -552,10 +572,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Library Item to Group",
[],
None,
"{library?, kind, item: name (graphic: id or name), group?: name (null or none: out of its group)} put an item in a group (out of the one it was in) → {library, kind, item, group: name or null}",
"{library?, kind, item: name (graphic: id or name), group?: name (null or none: out of its group), before?: item | after?: item (an item of the same kind, named as `item` is)} put an item in a group (out of the one it was in); with `before` or `after`, also move it just before or after that item in the order its kind is shown in, as dragging it in the Libraries panel does → {library, kind, item, group: name or null}",
always,
move_item
),
cmd!(
"library.moveGroup",
"Move Library Group",
[],
None,
"{library?, group: name, to: index} move a group to position `to` of the library's groups (0 = first; past the end: last), as dragging its header in the Libraries panel does → {library, group, to: where it went}",
always,
move_group
),
cmd!(
query "library.export",
"Export Library",
@@ -1023,13 +1052,30 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.moveItem";
let (id, kind, item) = item_param(s, p, C)?;
let group = str_param(p, "group");
let next_to = match (str_param(p, "before"), str_param(p, "after")) {
(Some(_), Some(_)) => return Err(bad(C, "give `before` or `after`, not both")),
(Some(b), None) => Some((b, false)),
(None, Some(a)) => Some((a, true)),
(None, None) => None,
};
let (key, to) = s.libraries.change(&id, |lib| {
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?;
let missing = |item: &str| bad(C, format!("the library has no item `{item}`"));
let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| missing(item))?;
let at = group.map(|g| group_at(lib, g, C)).transpose()?;
let next_to = next_to
.map(|(other, after)| Ok::<_, EngineError>((lib.item_ref(kind.item_kind(), other).ok_or_else(|| missing(other))?, after)))
.transpose()?;
if let Some((other, after)) = &next_to
&& *other != r
{
lib.place_next_to(&r, other, *after);
}
lib.ungroup(&r);
let key = r.item.clone();
let to = at.and_then(|i| lib.groups.get_mut(i)).map(|g| {
g.items.push(r);
// Next to the other item when that is in the group too, else last.
let near = next_to.as_ref().and_then(|(other, after)| g.items.iter().position(|x| x == other).map(|j| j + usize::from(*after)));
g.items.insert(near.unwrap_or(g.items.len()).min(g.items.len()), r);
g.name.clone()
});
Ok((key, to))
@@ -1037,6 +1083,22 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "group": to }))
}
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.moveGroup";
let id = library_id(s, p, C)?;
let group = group_param(p, C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing `to` (an index)"))?;
let (name, at) = s.libraries.change(&id, |lib| {
let i = group_at(lib, group, C)?;
let g = lib.groups.remove(i);
let at = usize::try_from(to).unwrap_or(usize::MAX).min(lib.groups.len());
let name = g.name.clone();
lib.groups.insert(at, g);
Ok((name, at))
})?;
Ok(json!({ "library": id, "group": name, "to": at }))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.export";
let id = library_id(s, p, C)?;
+78 -15
View File
@@ -243,7 +243,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
has_selection,
align
),
@@ -1251,12 +1251,17 @@ fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rec
ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect()
}
/// Move each object of `moves` by its offset, as undo step `label`; pattern tiles move too when
/// [`transform_patterns`] says so for `ids`. When every move is a rounding error (1e-9 pt or less
/// on both axes), the document and its undo history stay as they are.
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)]) -> Result<Value> {
/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position)
/// to its position, as undo step `label`; pattern tiles move too when [`transform_patterns`] says
/// so for `ids`. When every move is a rounding error (1e-9 pt or less on both axes), the document
/// and its undo history stay as they are.
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(usize, f64)]) -> Result<Value> {
let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() };
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) {
let guides: Vec<(usize, f64)> = {
let d = &s.doc()?.doc;
guides.iter().copied().filter(|&(i, pos)| d.guides.get(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect()
};
if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) && guides.is_empty() {
return ok();
}
s.edit(label, |d, _| {
@@ -1265,15 +1270,42 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &
n.transform(Affine::translate(*dv), sc);
}
}
for &(i, pos) in &guides {
if let Some(g) = d.guides.get_mut(i) {
g.pos = pos;
}
}
Ok(())
})?;
ok()
}
/// What `object.align` aligns to: `to`, else the key object when there is one, else the
/// selection.
/// What `object.align` aligns to: `to`, else the key object (or key guide) when there is one,
/// else the selection.
fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> {
str_param(p, "to").or_else(|| s.doc().ok()?.selection.key.map(|_| "key"))
str_param(p, "to").or_else(|| s.doc().ok()?.selection.has_key().then_some("key"))
}
/// The key guide `object.align` lines the objects up on (aligning to the key with one set,
/// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical.
fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> {
let st = s.doc().ok()?;
let g = st.doc.guides.get(st.selection.key_guide?)?;
(align_to(s, p) == Some("key")).then_some((g.pos, g.vertical))
}
/// Where Align puts the objects, `r`, and the axes it moves them along (x, y): a key guide gives
/// the line across its axis only (a vertical guide fixes x, a horizontal one y), so the other
/// axis stays as it is.
fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: Option<Rect>) -> Result<(Rect, bool, bool)> {
if let Some((pos, vertical)) = key_guide(s, p) {
return Ok((Rect::new(pos, pos, pos, pos), vertical, !vertical));
}
let r = match own {
Some(b) if align_to(s, p) != Some("artboard") => b,
_ => reference_rect(s, p, ids, preview)?,
};
Ok((r, true, true))
}
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
@@ -1328,7 +1360,8 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
if !s.doc()?.selection.anchors.is_empty() {
return align_anchors(s, p, &ids, (h, v));
}
let r = reference_rect(s, p, &ids, preview)?;
let (r, along_x, along_y) = align_reference(s, p, &ids, preview, None)?;
let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y));
let key = key_root(s, &ids);
let moves: Vec<(NodeId, Vec2)> = {
let d = &s.doc()?.doc;
@@ -1352,7 +1385,35 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect()
};
apply_moves(s, "Align", p, &ids, &moves)
let guides = guide_moves(s, p, r, h, v)?;
apply_moves(s, "Align", p, &ids, &moves, &guides)
}
/// The selected ruler guides that Align to the key object (or key guide) moves, and where: a
/// vertical guide goes to the left edge, centre or right edge of `r` that `h` names, a horizontal
/// one to the top, centre or bottom that `v` names. The key guide stays put, and so do guides
/// when aligning to the selection or the artboard.
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(usize, f64)>> {
if align_to(s, p) != Some("key") {
return Ok(vec![]);
}
let st = s.doc()?;
let at = |vertical: bool| match (vertical, h, v) {
(true, Some("left"), _) => Some(r.x0),
(true, Some("center"), _) => Some(r.center().x),
(true, Some("right"), _) => Some(r.x1),
(false, _, Some("top")) => Some(r.y0),
(false, _, Some("center")) => Some(r.center().y),
(false, _, Some("bottom")) => Some(r.y1),
_ => None,
};
Ok(st
.selection
.guides
.iter()
.filter(|&&i| Some(i) != st.selection.key_guide)
.filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?)))
.collect())
}
/// [`align`] of the selected anchor points (whole paths among them give all theirs): each point
@@ -1367,8 +1428,10 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st
let Some(bounds) = pts.iter().skip(1).fold(pts.first().map(|q| Rect::from_points(*q, *q)), |b, q| b.map(|b| b.union_pt(*q))) else {
return ok();
};
// shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does.
let r = if align_to(s, p) == Some("artboard") { reference_rect(s, p, ids, false)? } else { bounds };
// shortcut: no key anchor yet, so Align To Key Object aligns to the points' bounds as Selection does
// (or to a key guide).
let (r, along_x, along_y) = align_reference(s, p, ids, false, Some(bounds))?;
let (h, v) = (h.filter(|_| along_x), v.filter(|_| along_y));
let target = |q: Point| {
let x = match h {
Some("left") => r.x0,
@@ -1515,7 +1578,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
})
.collect();
apply_moves(s, "Distribute", p, &ids, &moves)
apply_moves(s, "Distribute", p, &ids, &moves, &[])
}
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1560,7 +1623,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
apply_moves(s, "Distribute Spacing", p, &ids, &moves)
apply_moves(s, "Distribute Spacing", p, &ids, &moves, &[])
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
+19 -7
View File
@@ -5,12 +5,13 @@
//! preference, and listed with `plugin.list` / `plugin.info`. Object filters run
//! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a
//! plug-in that fails leaves the document untouched); live-effect plug-ins are applied like any
//! effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`.
//! effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`; exporters
//! add a file format that `document.export` writes (`fileio::plugin_formats`).
use std::sync::{Arc, Mutex};
use serde_json::{Map, Value, json};
use vectorcraft_plugins::{Kind, Plugin, effect, objects, registry};
use vectorcraft_plugins::{Kind, Plugin, effect, export, objects, registry};
use super::*;
@@ -27,8 +28,13 @@ pub fn describe(p: &Plugin) -> Value {
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
"size": p.size(), "source": p.source(),
});
if m.kind == Kind::Effect {
v["effect"] = json!(effect::effect_id(&m.id));
match m.kind {
Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)),
Kind::Exporter => {
v["format"] = json!(export::format_id(&m.id));
v["extensions"] = json!(m.extensions);
}
Kind::Filter => {}
}
v
}
@@ -56,8 +62,14 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.run";
let plugin = plugin_by_id(C, p)?;
let m = plugin.manifest();
if m.kind != Kind::Filter {
return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id))));
match m.kind {
Kind::Filter => {}
Kind::Effect => {
return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id))));
}
Kind::Exporter => {
return Err(bad(C, format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id))));
}
}
// Bad parameters fail before anything runs.
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
@@ -206,7 +218,7 @@ pub fn specs() -> Vec<CommandSpec> {
"List Plug-ins",
[],
None,
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
always,
list
),
+39 -3
View File
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Cancel Key Object",
[],
None,
"{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more (the Selection tool: a click on one object of a selection of several; on the key again: no id)} the key object Align aligns to and Distribute Spacing spaces from; without `id` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members); any other id is an error that leaves the key as it was → {key} (the resulting key object, or null)",
"{id?: a selected top-level object (not a layer, a compound-path member or an object inside another selected object); one of two or more or the only one with ruler guides selected (the Selection tool: a click on one object of a selection of several, or of one with guides; on the key again: no id), guide?: index (a selected ruler guide, guide.list, with one or more objects selected: the Selection tool's click on a selected guide; Align lines the objects up on it and the guide stays put)} the key object Align aligns to and Distribute Spacing spaces from; without `id` or `guide` it cancels the key, as does a selection that holds only the key and objects inside it (one object, or a group key and its own members) and no ruler guide, or a key guide with no object selected or taken out of the selection; any other id or guide is an error that leaves the key as it was → {key, guide?} (the resulting key object, or null; guide: the key guide, left out without one)",
has_doc,
key
),
@@ -368,6 +368,29 @@ fn selection_result(s: &Session) -> Result<Value> {
fn key(s: &mut Session, p: &Value) -> Result<Value> {
let id = p.get("id").map(|value| checked_id(s, value, "select.key")).transpose()?;
let guide = match p.get("guide").filter(|v| !v.is_null()) {
Some(v) => {
Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("select.key", "`guide` must be a guide index (guide.list)"))?)
}
None => None,
};
if let Some(g) = guide {
let sel = &s.doc()?.selection;
if id.is_some() {
return Err(bad("select.key", "give `id` or `guide`, not both"));
}
if !sel.guides.contains(&g) {
return Err(bad("select.key", format!("guide {g} is not selected")));
}
if sel.is_empty() {
return Err(bad("select.key", "select objects to align to the guide"));
}
s.select(|_, sel| {
sel.key = None;
sel.key_guide = Some(g);
})?;
return key_result(s);
}
if let Some(id) = id {
let st = s.doc()?;
// A selected top-level object is the only top-level object among the selected ones of
@@ -377,8 +400,21 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("select.key", format!("object {id} is not a selected top-level object")));
}
}
s.select(|_, sel| sel.key = id)?;
Ok(json!({ "key": s.doc()?.selection.key }))
s.select(|_, sel| {
sel.key = id;
sel.key_guide = None;
})?;
key_result(s)
}
/// `{key}`, with `guide` when a guide is the key.
fn key_result(s: &Session) -> Result<Value> {
let sel = &s.doc()?.selection;
let mut out = json!({ "key": sel.key });
if let Some(g) = sel.key_guide {
out["guide"] = json!(g);
}
Ok(out)
}
/// `[[subpath, anchor]…]` anchor references of `cmd`: a malformed entry fails rather than being
+2
View File
@@ -258,6 +258,8 @@ fn document_base(s: &Session) -> Value {
"target": st.selection.target.map(|t| t.0),
// The key object Align aligns to and Distribute Spacing spaces from (`select.key`).
"key": st.selection.key.map(|k| k.0),
// The ruler guide that is the key instead (`select.key {guide}`): an index into `guide.list`.
"keyGuide": st.selection.key_guide,
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
"objects": d.node_count(),
+7
View File
@@ -774,6 +774,11 @@ pub(crate) fn deselect_guides(d: &Document, sel: &mut Selection) -> bool {
sel.remove(id);
}
sel.guides.clear();
sel.key_guide = None;
// A key object kept only for the guides to line up on goes with them.
if sel.key.is_some_and(|k| sel.objects.iter().all(|o| *o == k)) {
sel.key = None;
}
any
}
@@ -2149,6 +2154,8 @@ mod tests_intertwine;
#[cfg(test)]
mod tests_journal;
#[cfg(test)]
mod tests_keyguide;
#[cfg(test)]
mod tests_knockout;
#[cfg(test)]
mod tests_labspots;
+50 -2
View File
@@ -7,7 +7,7 @@ use vectorcraft_geom::Point;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
use crate::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
use crate::cmd::clipboard::{AI, BITMAP, EPS, FILE_HEAD, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour};
fn session() -> Session {
let mut s = Session::new();
@@ -241,7 +241,8 @@ fn copied_ai_files_paste_as_the_pdf_or_postscript_inside() {
let ps = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 40 40\n%%EndComments\n1 0 0 setrgbcolor\nnewpath 0 0 moveto 40 0 lineto 40 40 lineto closepath fill\nshowpage\n";
let of = |name: &str, bytes: &[u8]| file_flavour(name, &bytes[..bytes.len().min(FILE_HEAD)], &PASTE_ORDER);
for name in ["art.ai", "art.ait", "ART.AI"] {
assert_eq!(of(name, &pdf), Some(PDF), "{name}");
// Asked for as PDF; Paste reads it from its editing data (tests_aiimport).
assert_eq!(of(name, &pdf), Some(AI), "{name}");
assert_eq!(of(name, ps), Some(EPS), "{name}");
}
// Neither PDF nor PostScript: nothing to paste, whatever the name says.
@@ -315,6 +316,53 @@ fn copy_offers_flavours_by_the_preferences() {
assert_eq!((f[0].data.as_slice(), f[1].mime), (b"Words".as_slice(), SVG));
}
/// A PNG of `side` × `side` noise pixels: noise keeps it about `side² × 3` bytes, as a photo.
fn noise_png(side: u32) -> Vec<u8> {
let mut x: u32 = 0x9e37_79b9;
let px = (0..side * side * 3)
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
(x >> 24) as u8
})
.collect();
let mut out = vec![];
image::RgbImage::from_raw(side, side, px).unwrap().write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// #2213: a placed photo in the plain-text flavour made every paste read megabytes of SVG back
/// through the system clipboard. Past 1 MiB of embedded images the SVG goes out under its own type
/// and the PNG only; a small picture, vector art of any size and type keep their text.
#[test]
fn a_copy_with_a_large_image_offers_no_svg_text() {
let mut s = session();
let mimes = |s: &Session| s.clipboard_flavours().iter().map(|f| f.mime).collect::<Vec<_>>();
for (side, text) in [(700, false), (64, true)] {
let png = noise_png(side);
assert_eq!(png.len() > 1 << 20, !text, "{} bytes", png.len());
run(&mut s, "clipboard.importImage", json!({"dataBase64": vectorcraft_format::base64_encode(&png)}));
run(&mut s, "edit.paste", json!({}));
run(&mut s, "edit.copy", json!({}));
let want: &[&str] = if text { &[TEXT, SVG, PNG] } else { &[SVG, PNG] };
assert_eq!(mimes(&s), want, "{side} px");
assert_eq!(run(&mut s, "clipboard.flavours", json!({}))["flavours"], json!(want), "{side} px");
// The SVG still embeds the picture, for apps that read it.
let svg = s.clipboard_flavours().into_iter().find(|f| f.mime == SVG).unwrap();
assert!(String::from_utf8_lossy(&svg.data).contains("data:image/png;base64,"));
run(&mut s, "edit.clear", json!({}));
}
// Vector art of more than 1 MiB of SVG keeps its text: only embedded images count.
let rects: String =
(0..30_000).map(|i| format!(r##"<rect x="{}.25" y="{}.75" width="3.5" height="3.5" fill="#{:06x}"/>"##, i % 400, i / 100, i * 977)).collect();
run(&mut s, "clipboard.importSvg", json!({"svg": format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300">{rects}</svg>"#)}));
run(&mut s, "edit.paste", json!({}));
run(&mut s, "edit.copy", json!({}));
let f = s.clipboard_flavours();
assert!(f[0].mime == TEXT && f[0].data.len() > 1 << 20, "{} bytes of text", f[0].data.len());
}
/// The Type tool pastes the text it copied with its formatting, unless pasted text is kept plain.
fn paste_copied_range(plain: bool) -> f64 {
let mut s = session();
+1 -1
View File
@@ -48,7 +48,7 @@ fn type_gives_font_properties_and_gradients_css_gradients() {
let mut s = session();
let t = run(&mut s, "text.create", json!({"x": 20, "y": 50, "text": "Title", "size": 24}))["id"].as_u64().unwrap();
let text = css(&run(&mut s, "css.selection", json!({"ids": [t]}))).to_string();
for decl in ["font-family: 'Source Sans 3';", "font-size: 24px;", "color: #"] {
for decl in ["font-family: 'Source Sans 3', sans-serif;", "font-size: 24px;", "color: #"] {
assert!(text.contains(decl), "{decl} in {text}");
}
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 100, "height": 50}));
+185
View File
@@ -0,0 +1,185 @@
//! #2143: a ruler guide selected with objects can be the key object Align lines them up on.
use serde_json::{Value, json};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap())
}
fn bounds(s: &Session, id: NodeId) -> vectorcraft_geom::Rect {
s.doc().unwrap().doc.bounds_of(&[id], false).unwrap()
}
/// A Selection tool click at (`x`, `y`), with Shift when `shift`.
fn click(s: &mut Session, x: f64, y: f64, shift: bool) {
let (v, mods) = (ViewInfo::default(), Mods { shift, ..Default::default() });
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), v).unwrap();
}
}
#[test]
fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() {
let mut s = session();
let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 250.0));
s.execute("guide.add", &json!({"vertical": true, "pos": 500})).unwrap();
s.select_tool("selection", ViewInfo::default()).unwrap();
// Click an object, Shift-click the guide, then click the guide again.
click(&mut s, 110.0, 110.0, false);
click(&mut s, 500.0, 400.0, true);
assert_eq!((s.doc().unwrap().selection.objects.clone(), s.doc().unwrap().selection.guides.clone()), (vec![a], vec![0]));
click(&mut s, 500.0, 400.0, false);
assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "the guide is the key");
assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["keyGuide"], 0);
// Shift-click a third object: the guide stays the key.
click(&mut s, 310.0, 260.0, true);
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
assert_eq!(s.doc().unwrap().selection.key_guide, Some(0));
let (ya, yb) = (bounds(&s, a).y0, bounds(&s, b).y0);
s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap();
// Both centred on the guide's x; a vertical guide fixes no y, so they keep theirs.
for (id, y) in [(a, ya), (b, yb)] {
let r = bounds(&s, id);
assert!((r.center().x - 500.0).abs() < 1e-6 && (r.y0 - y).abs() < 1e-6, "{id:?}: {r:?}");
}
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 500.0, "the guide stays put");
s.execute("object.align", &json!({"horizontal": "left"})).unwrap();
assert!((bounds(&s, a).x0 - 500.0).abs() < 1e-6 && (bounds(&s, b).x0 - 500.0).abs() < 1e-6);
// A click on the key guide again: no key.
click(&mut s, 500.0, 400.0, false);
assert_eq!(s.doc().unwrap().selection.key_guide, None);
}
#[test]
fn one_object_aligns_to_a_horizontal_key_guide() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0);
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
assert_eq!(s.execute("select.key", &json!({"guide": 0})).unwrap(), json!({"key": null, "guide": 0}));
let x = bounds(&s, a).x0;
s.execute("object.align", &json!({"vertical": "bottom", "horizontal": "right"})).unwrap();
let r = bounds(&s, a);
assert!((r.y1 - 300.0).abs() < 1e-6 && (r.x0 - x).abs() < 1e-6, "{r:?}");
// `to` still wins.
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap();
assert!(bounds(&s, a).x0.abs() < 1e-6);
}
#[test]
fn select_key_takes_only_a_selected_guide_with_objects() {
let mut s = session();
let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 100.0));
for pos in [500, 600] {
s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap();
}
let err = |s: &mut Session, p: Value| assert!(s.execute("select.key", &p).is_err(), "{p}");
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
err(&mut s, json!({"guide": 1}));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
err(&mut s, json!({"guide": 1}));
s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap();
for bad in [json!({"guide": 7}), json!({"guide": -1}), json!({"guide": "x"}), json!({"guide": 1, "id": a.0})] {
err(&mut s, bad);
}
s.execute("select.key", &json!({"guide": 1})).unwrap();
// An object key replaces the guide key, and the other way round.
s.execute("select.key", &json!({"id": b.0})).unwrap();
assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (Some(b), None));
s.execute("select.key", &json!({"guide": 1})).unwrap();
assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (None, Some(1)));
// Deleting a guide before it keeps the key on the same guide.
s.execute("guide.remove", &json!({"index": 0})).unwrap();
assert_eq!(s.doc().unwrap().selection.key_guide, Some(0));
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 600.0);
// Taking the guide out of the selection, or every object, lets the key go.
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
assert_eq!(s.doc().unwrap().selection.key_guide, None);
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
s.execute("select.key", &json!({"guide": 0})).unwrap();
s.execute("select.toggle", &json!({"id": a.0})).unwrap();
assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "one object is enough");
s.execute("select.toggle", &json!({"id": b.0})).unwrap();
assert_eq!(s.doc().unwrap().selection.key_guide, None, "no object to align");
}
#[test]
fn selected_anchor_points_align_to_a_key_guide() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 400})).unwrap();
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap();
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
s.execute("select.key", &json!({"guide": 0})).unwrap();
s.execute("object.align", &json!({"horizontal": "center"})).unwrap();
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(a).unwrap().kind else { panic!("path") };
let p = path.subpaths[0].anchors[0].p;
assert_eq!(p.x, 400.0);
assert_eq!(path.subpaths[0].anchors[1].p.x, 140.0, "the other points stay");
}
#[test]
fn selected_guides_align_to_a_clicked_key_object() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 500})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 400})).unwrap();
s.select_tool("selection", ViewInfo::default()).unwrap();
// Click the object, Shift-click both guides, then click the object again: one object is
// enough to be the key while guides are selected.
click(&mut s, 110.0, 110.0, false);
click(&mut s, 500.0, 300.0, true);
click(&mut s, 300.0, 400.0, true);
assert_eq!(s.doc().unwrap().selection.guides, vec![0, 1]);
click(&mut s, 110.0, 110.0, false);
assert_eq!(s.doc().unwrap().selection.key, Some(a), "the object is the key");
let before = bounds(&s, a);
s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap();
let guides = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::<Vec<_>>();
assert_eq!(guides(&s), [120.0, 110.0], "the vertical guide on the centre's x, the horizontal one on its y");
assert_eq!(bounds(&s, a), before, "the key stays put");
s.execute("object.align", &json!({"horizontal": "right"})).unwrap();
assert_eq!(guides(&s), [140.0, 110.0], "horizontal alignment moves only the vertical guide");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s), [120.0, 110.0], "one undo step");
// Aligning to the artboard leaves the guides alone.
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap();
assert_eq!(guides(&s), [120.0, 110.0]);
// Deselecting the guides lets the lone key go.
s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.doc().unwrap().selection.key, None);
}
#[test]
fn guides_align_to_a_key_object_among_others_and_parallel_ones_to_a_key_guide() {
let mut s = session();
let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 250.0));
for pos in [500, 600] {
s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap();
}
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap();
s.execute("select.key", &json!({"id": b.0})).unwrap();
s.execute("object.align", &json!({"horizontal": "left"})).unwrap();
assert_eq!(bounds(&s, a).x0, 300.0);
assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0));
// Aligning to the selection leaves guides alone.
s.execute("object.align", &json!({"horizontal": "right", "to": "selection"})).unwrap();
assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0));
// A key guide stays put, and the other vertical guide lines up on it.
s.execute("guide.move", &json!({"index": 1, "pos": 700})).unwrap();
s.execute("select.key", &json!({"guide": 1})).unwrap();
s.execute("object.align", &json!({"horizontal": "center"})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::<Vec<_>>(), [700.0, 700.0]);
}
+55
View File
@@ -276,6 +276,61 @@ fn items_are_grouped_moved_and_ungrouped() {
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"][0]["groups"], 1);
}
/// The names of the current library's `list` ("colors", "graphics"…), in their order.
fn order(s: &mut Session, list: &str, key: &str) -> Vec<String> {
let got = run(s, "library.get", json!({}));
got[list].as_array().unwrap().iter().map(|v| v[key].as_str().unwrap().to_string()).collect()
}
/// Dragging in the Libraries panel: an item moves just before or after another of its kind, into
/// that one's group or out of every group, and groups move to another place in the list.
#[test]
fn items_and_groups_are_reordered() {
let mut s = session();
run(&mut s, "library.create", json!({"name": "Brand"}));
for (name, color) in [("Red", "#ff0000"), ("Green", "#00ff00"), ("Blue", "#0000ff"), ("Gray", "#808080")] {
run(&mut s, "library.add", json!({"kind": "fillColor", "color": color, "name": name}));
}
// Before an item, and after one; on itself it stays.
run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": "Blue", "before": "Red"}));
assert_eq!(order(&mut s, "colors", "name"), ["Blue", "Red", "Green", "Gray"]);
run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": "Blue", "after": "Gray"}));
assert_eq!(order(&mut s, "colors", "name"), ["Red", "Green", "Gray", "Blue"]);
run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": "Red", "before": "Red"}));
assert_eq!(order(&mut s, "colors", "name"), ["Red", "Green", "Gray", "Blue"]);
// Into a group next to an item there: the group lists it next to that one too.
run(
&mut s,
"library.createGroup",
json!({"name": "Cool", "items": [{"kind": "fillColor", "item": "Green"}, {"kind": "fillColor", "item": "Blue"}]}),
);
let r = run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": "Gray", "group": "Cool", "before": "Blue"}));
assert_eq!(r["group"], "Cool");
assert_eq!(order(&mut s, "colors", "name"), ["Red", "Green", "Gray", "Blue"]);
assert_eq!(groups(&mut s), [("Cool".into(), refs(&[("color", "Green"), ("color", "Gray"), ("color", "Blue")]))]);
// Next to an ungrouped item, without a group: out of its group.
run(&mut s, "library.moveItem", json!({"kind": "fillColor", "item": "Blue", "before": "Red"}));
assert_eq!(order(&mut s, "colors", "name"), ["Blue", "Red", "Green", "Gray"]);
assert_eq!(groups(&mut s), [("Cool".into(), refs(&[("color", "Green"), ("color", "Gray")]))]);
// Wrong asks change nothing.
assert!(s.execute("library.moveItem", &json!({"kind": "fillColor", "item": "Red", "before": "Nope"})).is_err());
assert!(s.execute("library.moveItem", &json!({"kind": "fillColor", "item": "Red", "before": "Blue", "after": "Gray"})).is_err());
assert!(s.execute("library.moveItem", &json!({"kind": "charStyle", "item": "Red", "before": "Blue"})).is_err(), "not a style");
assert_eq!(order(&mut s, "colors", "name"), ["Blue", "Red", "Green", "Gray"]);
// Groups: to an index, past the end to the last place.
run(&mut s, "library.createGroup", json!({"name": "Warm"}));
run(&mut s, "library.createGroup", json!({"name": "Neutral"}));
let names = |s: &mut Session| groups(s).into_iter().map(|(n, _)| n).collect::<Vec<_>>();
assert_eq!(run(&mut s, "library.moveGroup", json!({"group": "neutral", "to": 0}))["to"], 0);
assert_eq!(names(&mut s), ["Neutral", "Cool", "Warm"]);
assert_eq!(run(&mut s, "library.moveGroup", json!({"group": "Neutral", "to": 99}))["to"], 2);
assert_eq!(names(&mut s), ["Cool", "Warm", "Neutral"]);
assert!(s.execute("library.moveGroup", &json!({"group": "Nope", "to": 0})).is_err());
assert!(s.execute("library.moveGroup", &json!({"group": "Cool", "to": -1})).is_err());
assert!(s.execute("library.moveGroup", &json!({"group": "Cool"})).is_err());
assert_eq!(groups(&mut s)[0].1, refs(&[("color", "Green"), ("color", "Gray")]), "a group keeps its items when it moves");
}
/// A library goes out to a file with its art and groups and comes back as a new library, numbered
/// when its name is taken; the graphic it brings places in a document (#926).
#[test]
+88
View File
@@ -174,6 +174,94 @@ fn live_effect_plugins_reshape_editable_art_and_expand() {
assert_eq!(baked.unwrap().bounds(), triangle().bounds());
}
/// A live effect is given its object's fills and strokes, read only, and which of them it sits on;
/// a change of paint runs it again.
#[test]
fn live_effects_are_given_their_object_s_paints() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"}));
run(&mut s, "stroke.set", json!({"ids": [id.0], "weight": 5}));
// The red fill as the host writes paints: a JSON value, its keys sorted.
let red = serde_json::to_value(&node(&s, id).appearance.fill().unwrap().paint).unwrap().to_string();
let tri = json!({"objects": [{"path": triangle()}]}).to_string();
// Each draws the triangle while it is given what it looks for, else hands the geometry back.
let effect = |id: &str, given, needle: &str| tpl::when_given(&tpl::manifest(id, "effect", "{}"), given, needle, &tri);
install_wat(&mut s, &effect("test.engine.wide", tpl::Given::Input, r#""width":5.0"#));
install_wat(&mut s, &effect("test.engine.red", tpl::Given::Input, &format!(r#""fills":[{red}]"#)));
install_wat(&mut s, &effect("test.engine.second", tpl::Given::Params, r#""item":{"stroke":1}"#));
let effected = |s: &Session, item: Option<usize>| {
let n = node(s, id);
let path = n.path_data().unwrap().clone();
let ctx = vectorcraft_render::effects::GeomContext::of(&n);
let (effects, ctx) = match item {
Some(i) => match &n.appearance.items[i] {
vectorcraft_doc::AppearanceItem::Stroke(st) => (st.effects.clone(), ctx.item(&n.appearance.items[i])),
other => panic!("not a stroke: {other:?}"),
},
None => (n.appearance.effects.clone(), ctx),
};
vectorcraft_render::effects::apply_geometry_with(&effects, &path, path.bounds().unwrap(), &ctx)
};
let own = node(&s, id).path_data().unwrap().clone();
let remove = |s: &mut Session| run(s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": null}));
for effect in ["plugin.test.engine.wide", "plugin.test.engine.red"] {
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": null}));
assert_eq!(effected(&s, None), triangle(), "{effect}: given the 5 pt stroke and the red fill");
remove(&mut s);
}
// Run again once the stroke is thinner.
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.wide", "item": null}));
run(&mut s, "stroke.set", json!({"ids": [id.0], "weight": 3, "item": 1}));
assert_eq!(effected(&s, None), own, "a 3 pt stroke");
remove(&mut s);
// On the second of two strokes, and not on the first.
run(&mut s, "appearance.addStroke", json!({"ids": [id.0]}));
let top = node(&s, id).appearance.items.len() - 1;
for item in [1, top] {
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.second", "item": item}));
}
assert_eq!(effected(&s, Some(1)), own, "the first stroke");
assert_eq!(effected(&s, Some(top)), triangle(), "the second stroke");
}
/// Expand gives a plug-in effect on a fill the context drawing gives it (`_context.item`), so a
/// gradient fill is expanded into the shape the canvas fills.
#[test]
fn expanding_a_fill_gives_its_plug_in_effect_the_fill_it_sits_on() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}}));
let tri = json!({"objects": [{"path": triangle()}]}).to_string();
let on_fill = tpl::when_given(&tpl::manifest("test.engine.onfill", "effect", "{}"), tpl::Given::Params, r#""item":{"fill":0}"#, &tri);
install_wat(&mut s, &on_fill);
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onfill", "item": fill}));
// Drawn, the gradient fills the triangle: a point of it outside the rectangle is painted.
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let drawn = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 40, 40, vectorcraft_geom::Affine::IDENTITY, &opts);
assert_ne!(drawn.pixel(15, 1), [255, 255, 255, 255], "drawn as a triangle");
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.expand", json!({"steps": 2}));
// The gradient's strips sit in a clip group whose clipping path is the shape filled.
let group = s.doc().unwrap().selection.objects[0];
let clip = node(&s, group).children().unwrap()[0].clone();
assert_eq!(clip.path_data().unwrap().bounds(), triangle().bounds(), "the triangle the canvas fills");
}
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
#[test]
fn stroke_width_test_effect() {
let Some(dir) = std::env::var_os("CRAFT_PLUGIN_PAINTS_WASM") else { return };
let triangle = PathData::single(SubPath::polyline(&[Point::new(100.0, 230.0), Point::new(300.0, 230.0), Point::new(200.0, 80.0)], true));
let manifest = r#"{"id":"org.example.strokewidth","name":"Stroke Width (test plug-in)","version":"1","kind":"effect",
"description":"Draws a triangle while the object's stroke is 5 pt wide.","params":{}}"#;
let src = tpl::when_given(manifest, tpl::Given::Input, r#""width":5.0"#, &json!({"objects": [{"path": triangle}]}).to_string());
std::fs::write(std::path::Path::new(&dir).join("stroke-width-test-effect.wasm"), wat::parse_str(src).unwrap()).unwrap();
}
#[test]
fn plugin_management_commands() {
let mut s = session();
+29 -4
View File
@@ -1,5 +1,5 @@
//! The Photoshop-style raster effects (Effect › Artistic, Blur › Radial Blur and Smart Blur, Brush Strokes,
//! Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Halftone Pattern, Texture ›
//! Distort › Diffuse Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Chrome, Graphic Pen, Halftone Pattern and Reticulation, Texture ›
//! Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, Video): applied and
//! edited as commands, listed in the catalogue, drawn on the canvas, and written to PDF and SVG
//! as images of the effected object.
@@ -31,8 +31,16 @@ const BRUSH_STROKES: [(&str, &str); 8] = [
];
/// Effect › Artistic, at their defaults.
const ARTISTIC: [(&str, &str); 4] =
[("artistic.cutout", "{}"), ("artistic.filmGrain", "{}"), ("artistic.neonGlow", "{}"), ("artistic.posterEdges", "{}")];
const ARTISTIC: [(&str, &str); 8] = [
("artistic.cutout", "{}"),
("artistic.filmGrain", "{}"),
("artistic.neonGlow", "{}"),
("artistic.plasticWrap", "{}"),
("artistic.posterEdges", "{}"),
("artistic.smudgeStick", "{}"),
("artistic.sponge", "{}"),
("artistic.watercolor", "{}"),
];
/// Effect › Distort (Diffuse Glow with its highlights glowing from a low brightness up, as the
/// striped art's reds are dark).
@@ -44,7 +52,19 @@ const PIXELATE: [(&str, &str); 4] =
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
/// Effect › Sketch, at their defaults.
const SKETCH: [(&str, &str); 1] = [("sketch.halftonePattern", "{}")];
const SKETCH: [(&str, &str); 11] = [
("sketch.basRelief", "{}"),
("sketch.chalkCharcoal", "{}"),
("sketch.charcoal", "{}"),
("sketch.chrome", "{}"),
("sketch.graphicPen", "{}"),
("sketch.halftonePattern", "{}"),
("sketch.notePaper", "{}"),
("sketch.photocopy", "{}"),
("sketch.plaster", "{}"),
("sketch.reticulation", "{}"),
("sketch.stamp", "{}"),
];
/// Effect › Texture, at their defaults.
const TEXTURE: [(&str, &str); 6] = [
@@ -196,6 +216,11 @@ fn artistic_settings_survive_a_save_and_open() {
json!({"edgeThickness": 4.0, "edgeIntensity": 5.0, "posterization": 1.0}),
[json!({"edgeIntensity": 0.0}), json!({"posterization": 6.0})],
),
(
"artistic.plasticWrap",
json!({"highlightStrength": 18.0, "detail": 10.0, "smoothness": 8.0}),
[json!({"highlightStrength": 5.0}), json!({"highlightStrength": 0.0})],
),
] {
let (mut s, g) = striped();
// Black and a grey beside the reds: Cutout puts the grey with black at 2 levels and apart
+44
View File
@@ -816,6 +816,50 @@ fn artboard_set_props_scales_art_only_when_asked_and_resized() {
assert!(s.execute("artboard.setProps", &json!({"index": 9, "width": 10, "scaleArt": true})).is_err());
}
/// #1482: `artboard.setProps {artboards}` resizes several artboards in one undo step, and with
/// `scaleArt` each object scales once, with the artboard it was inside before the change: art that
/// the first artboard's new size moves into the second stays as the first one put it, and art
/// inside two overlapping artboards goes with the first one listed. An empty list, an artboard
/// listed twice, an entry without an index and an index that names no artboard are refused.
#[test]
fn artboard_set_props_resizes_several_artboards_and_scales_each_object_once() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2, "created": null})).unwrap();
s.execute("artboard.setProps", &json!({"index": 0, "x": 0, "y": 0})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "x": 120, "y": 0})).unwrap();
let art = rect(&mut s, 70.0, 10.0, 10.0, 10.0);
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let out = s
.execute(
"artboard.setProps",
&json!({"artboards": [{"index": 0, "width": 200}, {"index": 1, "width": 200, "name": " Back\t"}], "scaleArt": true}),
)
.unwrap();
assert_eq!(out["scaled"], json!([art.0]));
let boards = &s.doc().unwrap().doc.artboards;
assert_eq!((boards[0].rect, boards[1].rect), (Rect::new(0.0, 0.0, 200.0, 100.0), Rect::new(120.0, 0.0, 320.0, 100.0)));
assert_eq!(boards[1].name, "Back");
assert_eq!(bounds(&s, art), Rect::new(140.0, 10.0, 160.0, 20.0), "scaled once, with Artboard 1");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before);
// Artboard 2 over Artboard 1's right half: the art inside both goes with Artboard 2, listed first.
s.execute("artboard.setProps", &json!({"index": 1, "x": 50})).unwrap();
s.execute("artboard.setProps", &json!({"artboards": [{"index": 1, "width": 200}, {"index": 0, "width": 200}], "scaleArt": true})).unwrap();
assert_eq!(bounds(&s, art), Rect::new(90.0, 10.0, 110.0, 20.0));
let was = s.doc().unwrap().doc.clone();
for bad in [
json!({"artboards": []}),
json!({"artboards": [{"index": 0, "width": 50}, {"index": 0, "height": 50}]}),
json!({"artboards": [{"index": 0, "width": 50}, {"height": 50}]}),
json!({"artboards": [{"index": 0, "width": 50}, {"index": 2, "height": 50}]}),
] {
assert!(s.execute("artboard.setProps", &bad).is_err(), "{bad}");
assert_eq!(*s.doc().unwrap().doc, *was, "{bad}");
}
}
/// Move Artwork is independent of scaling and takes only fully contained art and board guides.
#[test]
fn coordinate_moves_honor_move_art_and_keep_resize_behavior() {
+10
View File
@@ -331,10 +331,20 @@ fn structured_junk() {
("effect.apply", json!({"effect": "pixelate.crystallize", "params": {"cellSize": -1e308}})),
("effect.apply", json!({"effect": "pixelate.mezzotint", "params": {"type": 5}})),
("effect.apply", json!({"effect": "pixelate.pointillize", "params": {"cellSize": "NaN"}})),
("effect.apply", json!({"effect": "artistic.smudgeStick", "params": {"strokeLength": 1e308, "highlightArea": "NaN", "intensity": [1]}})),
("effect.apply", json!({"effect": "artistic.sponge", "params": {"brushSize": -1e308, "definition": 1e308, "smoothness": null}})),
("effect.apply", json!({"effect": "artistic.watercolor", "params": {"brushDetail": "1e999", "shadowIntensity": -1, "texture": 1e308}})),
("effect.apply", json!({"effect": "artistic.cutout", "params": {"levels": 1e308, "edgeSimplicity": "NaN", "edgeFidelity": -1e308}})),
("effect.apply", json!({"effect": "artistic.neonGlow", "params": {"glowSize": -1e308, "glowBrightness": "1e999", "color": [1]}})),
("effect.apply", json!({"effect": "artistic.posterEdges", "params": {"edgeThickness": 1e308, "edgeIntensity": [], "posterization": -7}})),
("effect.apply", json!({"effect": "sketch.chrome", "params": {"detail": -1e308, "smoothness": "NaN"}})),
("effect.apply", json!({"effect": "sketch.graphicPen", "params": {"strokeLength": -1e308, "lightDarkBalance": "NaN", "strokeDirection": 3}})),
("effect.apply", json!({"effect": "sketch.halftonePattern", "params": {"size": -1e308, "contrast": "NaN", "patternType": 3}})),
("effect.apply", json!({"effect": "sketch.basRelief", "params": {"detail": "NaN", "smoothness": 1e308, "lightDirection": 3}})),
("effect.apply", json!({"effect": "sketch.charcoal", "params": {"thickness": 1e308, "detail": -1e308, "lightDarkBalance": [1]}})),
("effect.apply", json!({"effect": "sketch.photocopy", "params": {"detail": "1e999", "darkness": null}})),
("effect.apply", json!({"effect": "sketch.stamp", "params": {"lightDarkBalance": -1e308, "smoothness": "NaN"}})),
("effect.apply", json!({"effect": "sketch.reticulation", "params": {"density": "NaN", "foregroundLevel": -1e308, "backgroundLevel": 1e308}})),
("effect.apply", json!({"effect": "texture.craquelure", "params": {"crackSpacing": -1e308, "crackDepth": "NaN", "crackBrightness": 1e308}})),
("effect.apply", json!({"effect": "texture.grain", "params": {"intensity": 1e308, "contrast": [1], "grainType": 7}})),
("effect.apply", json!({"effect": "texture.mosaicTiles", "params": {"tileSize": "1e999", "groutWidth": -1, "lightenGrout": null}})),
+10 -1
View File
@@ -988,7 +988,8 @@ fn styles(what: &str, data: &str) -> Result<(), TestCaseError> {
}
/// Libraries panel library file `data` imported, then each kind's first item used on the rich
/// document and moved into its first group, the group deleted and the library exported (#926).
/// document, moved into its first group and next to its kind's last item, the group moved and
/// deleted and the library exported (#926).
fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "library.import", data, |s, r| {
let lib = r["id"].clone();
@@ -1001,9 +1002,17 @@ fn libraries(what: &str, data: &str) -> Result<(), TestCaseError> {
let _ = no_panic(s.execute("library.use", &json!({"library": lib, "kind": kind, "item": item[key]})));
let to = json!({"library": lib, "kind": kind, "item": item[key], "group": got["groups"][0]["name"]});
let _ = no_panic(s.execute("library.moveItem", &to));
// Next to the last item of its kind, whatever group that is in.
let last = got[list].as_array().and_then(|a| a.last()).map(|v| v[key].clone()).unwrap_or_default();
for side in ["before", "after"] {
let _ = no_panic(s.execute("library.moveItem", &json!({"library": lib, "kind": kind, "item": item[key], side: last})));
}
}
}
if let Some(g) = got["groups"].get(0) {
for to in [0, 1, 1_000_000] {
let _ = no_panic(s.execute("library.moveGroup", &json!({"library": lib, "group": g["name"], "to": to})));
}
let _ = no_panic(s.execute("library.deleteGroup", &json!({"library": lib, "group": g["name"]})));
}
let _ = no_panic(s.execute("library.export", &json!({"library": lib})));
+217
View File
@@ -0,0 +1,217 @@
//! Exporter plug-ins (`"kind": "exporter"`, docs/plugins.md › Exporters) through the engine: the
//! formats they add to `document.formats` and `document.export`, the document and parameters they
//! are given, and what is written when they succeed, decline, misbehave or are cancelled.
//!
//! A test binary of its own: installed plug-ins are process-wide, and the unit tests that check the
//! exact list of formats would see these ones.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::{EngineError, Session};
use vectorcraft_plugins::wat::{self as tpl, Echo};
/// A document with one rectangle.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
s
}
/// Installs the plug-in written in WAT `src` → its `plugin.list` entry.
fn install(s: &mut Session, src: &str) -> Value {
let wasm = wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}"));
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap()
}
/// The file a `document.export` without a path gives back.
fn exported(r: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(r["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
/// A fresh folder for files this test writes.
fn tmp_dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("vc-plugin-export-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn path_of(p: &std::path::Path) -> String {
p.to_string_lossy().into_owned()
}
#[test]
fn an_exporter_adds_a_write_only_format_while_it_is_installed() {
let mut s = session();
let entry = install(&mut s, &tpl::returning(&tpl::exporter("test.list", r#"["tsl","tsl2"]"#, "{}"), r#"{"dataBase64":""}"#));
assert_eq!((&entry["kind"], &entry["format"], &entry["extensions"]), (&json!("exporter"), &json!("plugin.test.list"), &json!(["tsl", "tsl2"])));
let formats = s.execute("document.formats", &json!({})).unwrap();
let listed = formats["formats"].as_array().unwrap().iter().find(|f| f["id"] == "plugin.test.list").expect("listed");
assert_eq!((&listed["label"], &listed["extensions"]), (&json!("Test test.list"), &json!(["tsl", "tsl2"])));
assert_eq!((&listed["read"], &listed["write"], &listed["plugin"]), (&json!(false), &json!(true), &json!("test.list")));
assert!(formats["writable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(!formats["readable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(fileio::export_extensions().contains(&"tsl"));
assert_eq!(fileio::format(".TSL2").map(|f| f.id), Some("plugin.test.list"), "by any of its extensions, in any case");
assert_eq!(fileio::format("plugin.test.list").map(|f| f.label), Some("Test test.list"));
s.execute("plugin.remove", &json!({"id": "test.list"})).unwrap();
let formats = s.execute("document.formats", &json!({})).unwrap();
assert!(!formats["writable"].as_array().unwrap().contains(&json!("plugin.test.list")));
assert!(fileio::format("tsl").is_none());
let e = s.execute("document.export", &json!({"format": "plugin.test.list"})).unwrap_err();
assert!(e.to_string().contains("unknown format"), "{e}");
}
/// The exporter is given the document as a native file's JSON: what `document.save` writes before
/// the data of large images, which stays behind (compact, where a save of a small document is
/// indented).
#[test]
fn the_exporter_is_given_the_native_file_s_json() {
let mut s = session();
install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.input", r#"["tsi"]"#, "{}"), Echo::Input));
// Noise, so the PNG is large enough to go after the JSON in a native file.
let mut seed = 1_u32;
let noise = image::RgbaImage::from_fn(64, 64, |_, _| {
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12_345);
let [r, g, b, _] = seed.to_be_bytes();
image::Rgba([r, g, b, 255])
});
let mut png = vec![];
noise.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(png.len() >= vectorcraft_format::INLINE_MAX, "{} bytes", png.len());
s.execute("file.place", &json!({"name": "noise.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
let r = s.execute("document.export", &json!({"format": "plugin.test.input"})).unwrap();
assert_eq!(r["format"], "plugin.test.input");
let given = exported(&r);
let saved = exported(&s.execute("document.save", &json!({})).unwrap());
assert!(saved.len() > given.len() + png.len(), "the image data follows the JSON in the saved file");
let doc: Value = serde_json::from_slice(&given).unwrap();
let saved_json: Value = serde_json::from_slice(vectorcraft_format::json_part(&saved)).unwrap();
assert_eq!(doc, saved_json, "the saved file's JSON");
assert!(!given.contains(&b'\n'), "without indentation");
assert_eq!((&doc["format"], &doc["version"]), (&json!("vectorcraft"), &json!(vectorcraft_format::VERSION)));
let images = doc["images"].as_object().unwrap();
let image = images.values().next().unwrap();
assert_eq!(images.len(), 1);
assert!(image.get("blob").is_some() && image.get("data").is_none(), "{image}");
}
#[test]
fn the_exporter_is_given_its_parameters_and_the_file_it_writes() {
let dir = tmp_dir("params");
let mut s = session();
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4},"mode":{"type":"choice","options":["fast","fine"],"default":"fast"}}"#;
install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.params", r#"["tsp","tsq"]"#, params), Echo::Params));
let sent = |r: &Value| -> Value { serde_json::from_slice(&exported(r)).unwrap() };
// The defaults, and without a path its first extension and the document's name.
let r = s.execute("document.export", &json!({"format": "plugin.test.params"})).unwrap();
let got = sent(&r);
assert_eq!((&got["density"], &got["mode"]), (&json!(4.0), &json!("fast")));
assert_eq!((&got["_context"]["mode"], &got["_context"]["extension"]), (&json!("export"), &json!("tsp")));
let title = s.doc().unwrap().doc.title.clone();
assert_eq!(got["_context"]["name"], json!(title));
// Given ones, clamped as a filter's are, and the path's extension when the exporter lists it.
let path = dir.join("Poster.tsq");
let r = s.execute("document.export", &json!({"path": path_of(&path), "params": {"density": 12, "mode": "fine"}})).unwrap();
assert_eq!(r["format"], "plugin.test.params");
let written: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(written, json!({"density": 10.0, "mode": "fine", "_context": {"mode": "export", "extension": "tsq", "name": "Poster"}}));
// Bad parameters fail before the plug-in runs, and nothing is written.
let bad = dir.join("Bad.tsp");
let e = s.execute("document.export", &json!({"path": path_of(&bad), "params": {"mode": "slow"}})).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{e}");
assert!(!bad.exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// Only the selected objects, with `selectedOnly`, as for the built-in formats.
#[test]
fn selected_only_hands_over_the_selection() {
let mut s = session();
install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.selection", r#"["tse"]"#, "{}"), Echo::Input));
let ellipse = s.execute("shape.ellipse", &json!({"x": 200, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": [ellipse]})).unwrap();
let count = |r: &Value| String::from_utf8(exported(r)).unwrap().matches("\"anchors\"").count();
let all = count(&s.execute("document.export", &json!({"format": "plugin.test.selection"})).unwrap());
let selected = count(&s.execute("document.export", &json!({"format": "plugin.test.selection", "selectedOnly": true})).unwrap());
assert!(selected >= 1 && selected < all, "{selected} of {all} paths");
}
/// A built-in format keeps its extension: an exporter that lists `svg` too writes it only when
/// asked for by its format id.
#[test]
fn built_in_formats_keep_their_extensions() {
let dir = tmp_dir("builtin");
let mut s = session();
install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.svgtoo", r#"["tsv","svg"]"#, "{}"), Echo::Params));
assert_eq!(fileio::format("svg").map(|f| f.id), Some("svg"));
let svg = dir.join("a.svg");
s.execute("document.export", &json!({"path": path_of(&svg)})).unwrap();
assert!(String::from_utf8(std::fs::read(&svg).unwrap()).unwrap().contains("<svg"), "the built-in SVG");
s.execute("document.export", &json!({"path": path_of(&svg), "format": "plugin.test.svgtoo"})).unwrap();
let sent: Value = serde_json::from_slice(&std::fs::read(&svg).unwrap()).unwrap();
assert_eq!(sent["_context"]["extension"], "svg", "an extension it lists, asked for by its format id");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn declines_and_bad_output_write_nothing_and_warnings_come_back() {
let dir = tmp_dir("output");
let mut s = session();
let cases = [
("test.declines", r#"{"error":"Nothing to plot."}"#, "Nothing to plot."),
("test.badbase64", r#"{"dataBase64":"@@@"}"#, "`dataBase64` is not valid base64"),
("test.nodata", r#"{"warnings":[]}"#, "dataBase64"),
];
for (id, output, why) in cases {
install(&mut s, &tpl::returning(&tpl::exporter(id, r#"["tsd"]"#, "{}"), output));
let path = dir.join(format!("{id}.tsd"));
let e = s.execute("document.export", &json!({"path": path_of(&path), "format": format!("plugin.{id}")})).unwrap_err();
assert!(e.to_string().contains(why), "{id}: {e}");
assert!(!path.exists(), "{id}: nothing written");
}
install(
&mut s,
&tpl::returning(&tpl::exporter("test.warns", r#"["tsw"]"#, "{}"), r#"{"dataBase64":"SGk=","warnings":["3 colours over the limit"]}"#),
);
let path = dir.join("w.tsw");
let r = s.execute("document.export", &json!({"path": path_of(&path)})).unwrap();
assert_eq!((r["bytes"].as_u64(), &r["warnings"]), (Some(2), &json!(["3 colours over the limit"])));
assert_eq!(std::fs::read(&path).unwrap(), b"Hi");
// An exporter is not a filter.
let e = s.execute("plugin.run", &json!({"id": "test.warns"})).unwrap_err();
assert!(e.to_string().contains("document.export"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Background Export's Cancel stops an exporter between two slices of its budget: here one that
/// never returns, cancelled from another thread once it is running.
#[test]
fn a_cancelled_export_stops_the_exporter() {
let mut s = session();
install(&mut s, &tpl::module(&tpl::exporter("test.spins", r#"["tss"]"#, "{}"), "(loop $l (br $l)) (i64.const 0)", "", 1));
let token = vectorcraft_doc::cancel::CancelToken::new();
let cancel = token.clone();
let wait = Duration::from_millis(300);
let canceller = std::thread::spawn(move || {
std::thread::sleep(wait);
cancel.cancel();
});
let t = Instant::now();
let r = vectorcraft_doc::cancel::scope(&token, None, || s.execute("document.export", &json!({"format": "plugin.test.spins"})));
let took = t.elapsed();
canceller.join().unwrap();
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
assert!(took >= wait && took < Duration::from_secs(10), "stopped while running, not before: {took:?}");
}
+7
View File
@@ -163,6 +163,13 @@ fn preview_mime(bytes: &[u8]) -> &'static str {
if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { vectorcraft_doc::links::PROXY_MIME }
}
/// The JSON of a native file without the data after it (version 4 keeps images there): a
/// complete JSON value whose `images` give each image's MIME type and where its data was, without
/// the data.
pub fn json_part(file: &[u8]) -> &[u8] {
split_tail(file).0
}
/// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON
/// value.
fn split_tail(text: &[u8]) -> (&[u8], &[u8]) {
+13
View File
@@ -58,6 +58,19 @@ fn small_files_stay_plain_json_and_older_versions_inline() {
assert_eq!(load(&v3).unwrap().images["big"].bytes, big.images["big"].bytes);
}
/// `json_part` (what exporter plug-ins are given): the whole JSON value, without the data after
/// it; a file without such data is its JSON already.
#[test]
fn json_part_is_the_json_without_the_data_after_it() {
let big = save_with(&doc_with_image("big", INLINE_MAX + 10), &SaveOptions::default()).unwrap();
let json = vectorcraft_format::json_part(&big);
assert!(json.len() < big.len() - INLINE_MAX && !has_tail(json));
let v: Value = serde_json::from_slice(json).unwrap();
assert!(v["images"]["big"].get("blob").is_some() && v["images"]["big"].get("data").is_none(), "{}", v["images"]);
let small = save_with(&doc_with_image("small", 100), &SaveOptions::default()).unwrap();
assert_eq!(vectorcraft_format::json_part(&small), &small[..]);
}
#[test]
fn damaged_offsets_are_refused_not_a_crash() {
let d = doc_with_image("big", INLINE_MAX + 10);
+17 -2
View File
@@ -130,10 +130,10 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?;
}
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
let (layers, overprinted) = (ex.layers, ex.overprinted);
let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible);
w.absorb(ex);
let (bytes, more) = w.finish(left_out)?;
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted)?;
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted, invisible)?;
// The writer gives the file its creation date as its modification date too.
let bytes = match opts.modified.filter(|m| Some(*m) != created_at) {
Some(modified) => crate::post::modification_date(bytes, modified)?,
@@ -597,6 +597,8 @@ pub(crate) struct Exporter<'a> {
pub overprint: bool,
/// An overprinting fill or stroke was drawn so.
pub overprinted: bool,
/// Characters with neither fill nor stroke were written as text in forms marked invisible.
pub invisible: bool,
/// Inline graphics being drawn inside inline graphics (text in a symbol shown inline in
/// text…): deeper ones are left out.
inline_depth: u32,
@@ -639,6 +641,7 @@ impl<'a> Exporter<'a> {
in_layer: 0,
overprint: false,
overprinted: false,
invisible: false,
inline_depth: 0,
left_out: HashSet::new(),
}
@@ -1564,6 +1567,18 @@ impl Exporter<'_> {
}
s.pop();
});
// Characters with neither fill nor stroke (a scanned page's recognized text) are still
// text: written invisible (rendering mode 3), so they can still be selected and searched.
if let Some(text) = plain.as_deref().filter(|_| fill.is_none() && !run.style.has_stroke()) {
self.invisible = true;
crate::forms::form(s, Mark::Invisible, |s| {
s.push_transform(&text_space);
// The paint only lets the writer write the text; the glyphs it can't write
// as text draw nothing.
self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)));
s.pop();
});
}
s.push_transform(&text_space);
let path = run.style.has_stroke().then(|| to_path(s, &bp)).flatten();
s.pop();
+62 -10
View File
@@ -1,9 +1,10 @@
//! What the PDF writer has no operators for — optional content (PDF layers) and overprint — drawn
//! as form XObjects of their own (without a transparency group, so they draw as if in place),
//! each starting with a mark: an empty clip whose first point says what the form is. Marked
//! forms also stay apart (the writer shares identical forms). [`finish`] completes them in the
//! written file: a layer's form is drawn as the layer's optional content (`/OC … BDC`), and an
//! overprinting form sets a graphics state that overprints first.
//! What the PDF writer has no operators for — optional content (PDF layers), overprint and
//! invisible text — drawn as form XObjects of their own (without a transparency group, so they
//! draw as if in place), each starting with a mark: an empty clip whose first point says what the
//! form is. Marked forms also stay apart (the writer shares identical forms). [`finish`] completes
//! them in the written file: a layer's form is drawn as the layer's optional content (`/OC …
//! BDC`), an overprinting form sets a graphics state that overprints first, and the text of an
//! invisible form is drawn in rendering mode 3 (neither filled nor stroked).
use std::collections::HashMap;
use std::io::Read;
@@ -28,6 +29,8 @@ pub(crate) enum Mark {
Layer(usize),
/// A fill or stroke that overprints.
Overprint,
/// Text that draws nothing (rendering mode 3): characters with neither fill nor stroke.
Invisible,
}
/// A layer written as a PDF layer (an optional content group).
@@ -58,6 +61,8 @@ pub(crate) fn pdf_layers(doc: &Document) -> Vec<PdfLayer<'_>> {
const LAYER_X: i32 = -32000;
/// The x coordinate of an overprint mark's first point.
const OVERPRINT_X: i32 = -32001;
/// The x coordinate of an invisible text mark's first point.
const INVISIBLE_X: i32 = -32002;
impl Mark {
fn at(self) -> (f32, f32) {
@@ -65,6 +70,7 @@ impl Mark {
// Exact in `f32` up to 2^24 layers.
Self::Layer(i) => (LAYER_X as f32, i as f32),
Self::Overprint => (OVERPRINT_X as f32, 0.0),
Self::Invisible => (INVISIBLE_X as f32, 0.0),
}
}
}
@@ -100,7 +106,7 @@ const LAYER_OC: &str = "VCoc";
const HEAD: u64 = 64;
fn bad(why: &str) -> PdfError {
PdfError::Write(format!("can't write PDF layers or overprint: {why}"))
PdfError::Write(format!("can't write PDF layers, overprint or invisible text: {why}"))
}
/// An object of the written file with a dictionary, and its stream's [`StreamSpans`].
@@ -149,6 +155,7 @@ impl Object<'_> {
match x {
_ if x == i64::from(LAYER_X) => usize::try_from(y).ok().map(Mark::Layer),
_ if x == i64::from(OVERPRINT_X) && y == 0 => Some(Mark::Overprint),
_ if x == i64::from(INVISIBLE_X) && y == 0 => Some(Mark::Invisible),
_ => None,
}
}
@@ -225,14 +232,52 @@ fn mark_layers(content: &[u8], forms: &HashMap<&[u8], usize>) -> Option<(Vec<u8>
Some((out, layers))
}
/// `content` (an invisible form's) with its text drawn in rendering mode 3: the mode set first,
/// and every mode the writer sets (it sets one for each run of glyphs) made 3. `None` when the
/// content can't be read to its end.
fn invisible_text(content: &[u8]) -> Option<Vec<u8>> {
let mut lx = Lexer::at(content, 0);
let mut operand = None;
let mut modes = vec![];
let mut read = 0;
while let Some((tok, start, end)) = lx.next() {
read = end;
operand = match tok {
Tok::Word(b"Tr") => {
modes.extend(operand);
None
}
Tok::Word(w) if int(w).is_some() => Some((start, end)),
// Inline image data isn't PDF syntax (the writer writes none).
Tok::Word(b"ID") => return None,
_ => None,
};
}
// The lexer also stops at a string it can't read: a mode after it would be missed.
if !content.get(read..)?.iter().all(u8::is_ascii_whitespace) {
return None;
}
let mut out = Vec::with_capacity(content.len() + 8);
out.extend_from_slice(b"3 Tr\n");
let mut at = 0;
for (start, end) in modes {
out.extend_from_slice(content.get(at..start)?);
out.push(b'3');
at = end;
}
out.extend_from_slice(content.get(at..)?);
Some(out)
}
/// Finish the marked forms of `pdf` (as [`crate::export`] writes `doc` with `set`): with
/// `layers`, each layer and sublayer (but template layers) becomes an optional content group —
/// named as the layer, off when it is hidden, not printed (`/PrintState /OFF`) when its Print
/// option is off, locked when it is locked — and the uses of its forms its optional content;
/// with `overprint`, the overprinting forms get a graphics state that overprints (`/OP`, `/op`,
/// `/OPM 1`; `/OPM 0` in PDF/A, which allows no other with ICC-based CMYK colours).
pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bool, overprint: bool) -> Result<Vec<u8>, PdfError> {
if !layers && !overprint {
/// `/OPM 1`; `/OPM 0` in PDF/A, which allows no other with ICC-based CMYK colours); with
/// `invisible`, the text of the invisible forms is drawn in rendering mode 3.
pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bool, overprint: bool, invisible: bool) -> Result<Vec<u8>, PdfError> {
if !layers && !overprint && !invisible {
return Ok(pdf);
}
let xref = Xref::read(&pdf).ok_or_else(|| bad("the written PDF has no cross-reference table"))?;
@@ -253,6 +298,13 @@ pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bo
add_resources(&mut patch, res, "ExtGState", &format!("/{OVERPRINT_GS} {gs} 0 R"))?;
}
}
if invisible {
for (n, _) in marks.iter().filter(|(_, m)| **m == Mark::Invisible) {
let Some(o) = objects.get(n) else { continue };
let content = o.data(&pdf, u64::MAX).ok_or_else(|| bad("an invisible text form can't be read"))?;
o.rewrite(&mut patch, &invisible_text(&content).ok_or_else(|| bad("an invisible text form can't be read"))?)?;
}
}
if layers {
write_layers(&pdf, &xref, &objects, &marks, &mut patch, doc, set)?;
for (n, _) in marks.iter().filter(|(_, m)| matches!(m, Mark::Layer(_))) {
+34 -16
View File
@@ -149,10 +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: text drawn invisibly (text rendering mode 3, as a scanned page's OCR layer is) that
/// can't come in as type (text read as outlines, or a Type 3 font) was left out: as outlines it
/// would draw nothing (#2675).
pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's searchable OCR layer) that couldn't be read as type 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
@@ -1654,13 +1654,14 @@ impl<'p> Builder<'p> {
}
/// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps
/// its outline (no Unicode text, mirrored, or not type mode).
/// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text,
/// type with neither fill nor stroke.
fn type_glyph(
&mut self,
o: &craft_pdf::interpret::font::OutlineGlyph,
m: Affine,
scale: f64,
paint: &craft_pdf::interpret::Paint<'_>,
paint: Option<&craft_pdf::interpret::Paint<'_>>,
stroke: Option<&StrokeProps>,
overprint: bool,
) -> bool {
@@ -1692,7 +1693,8 @@ impl<'p> Builder<'p> {
Some(BfString::Char(c)) => c.to_string(),
Some(BfString::String(s)) => s,
None => {
if o.as_unicode().is_some_and(|u| replacement(&u)) {
// Invisible text isn't kept as outlines: the caller says it was left out.
if paint.is_some() && o.as_unicode().is_some_and(|u| replacement(&u)) {
let name = self.font_name(key, o).family;
self.warn(&format!("text in {name} {UNNAMED_CHARACTERS_NOTE}"));
}
@@ -1718,16 +1720,18 @@ impl<'p> Builder<'p> {
{
let decided = match self.matched.get(&key) {
Some(m) => m.clone(),
// A glyph that can't be compared (several characters, a vertical form) decides
// nothing: it takes the face of its name until one that can be compared does.
None if comparable(&text, upright).is_none() => Some(face.clone()),
// A glyph that can't be compared (several characters, a vertical form) or that
// shows nothing (invisible text) decides nothing: it takes the face of its name
// until one that can be compared does.
None if paint.is_none() || comparable(&text, upright).is_none() => Some(face.clone()),
None => {
let m = matching_face(face, &text, o, upright);
self.matched.insert(key, m.clone());
m
}
};
let Some(f) = decided else {
// Invisible text shows no glyphs that could differ: it keeps the face of its name.
let Some(f) = decided.or_else(|| paint.is_none().then(|| face.clone())) else {
self.warn(UNMATCHED_GLYPHS_NOTE);
return false;
};
@@ -1745,8 +1749,14 @@ impl<'p> Builder<'p> {
None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(),
None => named(&info.face),
};
let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true));
let stroke = stroke.map(|p| (paint.clone(), line_width(p, scale)));
let (paint, opacity) = match paint {
Some(p) => {
let (paint, opacity) = self.paint(p, stroke.is_some(), self.walked(true));
(Some(paint), opacity)
}
None => (None, 1.0),
};
let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale)));
let look = Look {
font: key,
family: info.family,
@@ -1756,7 +1766,7 @@ impl<'p> Builder<'p> {
h_scale: at.h_scale,
overprint_fill: stroke.is_none() && overprint,
overprint_stroke: stroke.is_some() && overprint,
fill: stroke.is_none().then_some(paint),
fill: if stroke.is_none() { paint } else { None },
stroke,
};
let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
@@ -1967,8 +1977,16 @@ impl<'a> Device<'a> for Builder<'_> {
) {
self.pending = false;
let stroke = match draw_mode {
// Invisible text (a scanned page's recognized text, over the scan) draws nothing but
// is still text: type with neither fill nor stroke, so it stays searchable.
GlyphDrawMode::Invisible => {
self.warn(INVISIBLE_TEXT_NOTE);
let typed = match glyph {
Glyph::Outline(o) => self.type_glyph(o, transform * glyph_transform, mean_scale(transform), None, None, false),
Glyph::Type3(_) => false,
};
if !typed {
self.warn(INVISIBLE_TEXT_NOTE);
}
return;
}
GlyphDrawMode::Fill => None,
@@ -1978,7 +1996,7 @@ impl<'a> Device<'a> for Builder<'_> {
Glyph::Outline(o) => {
let scale = mean_scale(transform);
let overprint = self.glyph_overprint(stroke.is_some());
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) {
if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) {
return;
}
self.flush_text();
+3 -2
View File
@@ -88,14 +88,15 @@ pub(crate) struct Look {
impl Look {
/// Can a glyph drawn with `other` join a run drawn with this? A fill joins a run whose
/// glyphs were also stroked over (fill and stroke rendering draws each glyph twice).
/// glyphs were also stroked over (fill and stroke rendering draws each glyph twice); invisible
/// text (neither fill nor stroke) joins only invisible text.
fn takes(&self, other: &Look) -> bool {
self.font == other.font
&& (self.size - other.size).abs() <= self.size * 0.01
&& (self.h_scale - other.h_scale).abs() < 0.5
&& self.fill == other.fill
&& self.overprint_fill == other.overprint_fill
&& (other.stroke.is_none() || (self.stroke == other.stroke && self.overprint_stroke == other.overprint_stroke))
&& ((other.stroke.is_none() && other.fill.is_some()) || (self.stroke == other.stroke && self.overprint_stroke == other.overprint_stroke))
}
fn style(&self) -> CharStyle {
+2 -1
View File
@@ -422,11 +422,12 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet<Strin
w.page(ex, &s.sheet())?;
}
let overprinted = exporters.iter().any(|ex| ex.overprinted);
let invisible = exporters.iter().any(|ex| ex.invisible);
for ex in exporters {
w.absorb(ex);
}
let (bytes, more) = w.finish(left_out)?;
let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted)?;
let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted, invisible)?;
let mut warnings = plan.warnings;
for m in more {
if !warnings.contains(&m) {
+37
View File
@@ -309,6 +309,43 @@ fn text_becomes_point_type() {
assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some("<Text Outlines>")));
}
/// Invisible text (rendering mode 3: a scanned page's recognized text, laid over the scan) is
/// type with neither fill nor stroke, and the visible text keeps its paint. On a line with
/// stroked text it is a run of its own, not stroked. As outlines it would draw nothing: it is left
/// out, and the open says so (#2675).
#[test]
fn invisible_text_becomes_type_without_paint() {
let bytes = one_page(
"0.5 g 10 40 80 40 re f \
BT /F1 10 Tf 3 Tr 12 70 Td (Invisible one) Tj 0 -12 Td (Invisible two) Tj ET \
BT /F1 10 Tf 0 g 0 Tr 12 20 Td (Visible) Tj ET \
BT /F1 10 Tf 1 0 0 RG 1 Tr 12 90 Td (Out) Tj 3 Tr (line) Tj ET",
HELVETICA,
&[],
);
let r = open_with(&bytes, |_| {});
let t = texts(&r.document);
let text = |o: &vectorcraft_doc::TextObject| o.runs.iter().map(|r| r.text.as_str()).collect::<String>();
assert_eq!(t.iter().map(text).collect::<Vec<_>>(), ["Invisible one", "Invisible two", "Visible", "Outline"], "{t:?}");
let unpainted = |st: &vectorcraft_doc::CharStyle| st.fill.is_none() && !st.has_stroke();
for o in &t[..2] {
assert!(o.runs.iter().all(|r| unpainted(&r.style)), "{:?}", o.runs);
}
assert!(t[2].runs.iter().all(|r| !r.style.fill.is_none()), "{:?}", t[2].runs);
let runs: Vec<(&str, bool)> = t[3].runs.iter().map(|r| (r.text.as_str(), unpainted(&r.style))).collect();
assert_eq!(runs, [("Out", false), ("line", true)]);
assert!(t[3].runs[0].style.has_stroke());
assert!(!r.warnings.iter().any(|w| w == INVISIBLE_TEXT_NOTE), "{:?}", r.warnings);
// As outlines: only the visible text's.
let r = open_with(&bytes, |o| o.text_as = TextAs::Outlines);
assert!(texts(&r.document).is_empty());
assert!(r.warnings.iter().any(|w| w == INVISIBLE_TEXT_NOTE), "{:?}", r.warnings);
let outlines: Vec<Rect> =
all(&r.document).iter().filter(|n| n.name.as_deref() == Some("<Text Outlines>")).filter_map(|n| n.geometric_bounds()).collect();
// y down: "Visible" sits on 80, "Out" on 10; the invisible lines on 30 and 42 left nothing.
assert!(!outlines.is_empty() && outlines.iter().all(|b| b.y1 < 20.0 || b.y0 > 70.0), "{outlines:?}");
}
#[test]
fn font_names_resolve_to_available_families() {
let f = crate::import_text::Families::available();
+26
View File
@@ -160,6 +160,32 @@ fn a_glyph_whose_unicode_names_another_letter_stays_outlined() {
assert!(r.warnings.iter().any(|w| w.contains("differ from the installed font")), "{:?}", r.warnings);
}
/// Invisible text (rendering mode 3) shows no glyphs that could differ from the installed font:
/// the glyph of `l` labelled `W` comes in as type showing `W`, and nothing is said to be kept as
/// outlines (#2675).
#[test]
fn invisible_text_is_type_whatever_its_glyphs_look_like() {
let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();
let l = font.glyph_for('l');
let mut pdf = krilla::Document::new_with(krilla::SerializeSettings { compress_content_streams: false, ..Default::default() });
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
let mut s = page.surface();
let kfont = Font::new(SOURCE_SANS.into(), 0).unwrap();
let glyph = krilla::text::KrillaGlyph::new(krilla::text::GlyphId::new(l), 0.25, 0.0, 0.0, 0.0, 0..1, None);
s.draw_glyphs(KPoint::from_xy(20.0, 50.0), &[glyph], kfont, "W", 24.0, false);
s.finish();
page.finish();
let mut bytes = pdf.finish().unwrap();
// Filled (mode 0) made invisible: the same length, so the cross-reference table still holds.
let at = bytes.windows(4).position(|w| w == b"0 Tr").expect("a rendering mode");
bytes[at] = b'3';
let r = import_with_report(&bytes, &ImportOptions::default()).unwrap();
let t = texts(&r.document);
assert_eq!(t.iter().map(TextObject::plain_text).collect::<Vec<_>>(), ["W"]);
assert!(t[0].runs.iter().all(|r| r.style.fill.is_none() && !r.style.has_stroke()), "{:?}", t[0].runs);
assert!(!r.warnings.iter().any(|w| is_outlines_note(w) || w == INVISIBLE_TEXT_NOTE), "{:?}", r.warnings);
}
/// `font` with each of `names` (`from`, `to`, the same length) replaced, in ASCII and UTF-16.
fn renamed(font: &[u8], names: &[(&str, &str)]) -> Vec<u8> {
let mut bytes = font.to_vec();
+20 -10
View File
@@ -178,31 +178,41 @@ 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.
/// #2675: text in rendering mode 3 (invisible, as a scanned page's OCR layer) comes in as type
/// with neither fill nor stroke. Where it can't come in as type (text read as outlines) it is
/// left out, and the import says so. 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![]);
let warned = r.warnings.iter().any(|w| w == crate::INVISIBLE_TEXT_NOTE);
assert_eq!(warned, text_as == TextAs::Outlines, "{text_as:?}: {:?}", r.warnings);
// What came in: the type's text and whether it is painted, and where the painted 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 painted, mut unpainted, mut tops) = (String::new(), String::new(), vec![]);
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
text.push_str(&t.plain_text());
for run in &t.runs {
let out = if run.style.fill.is_none() && !run.style.has_stroke() { &mut unpainted } else { &mut painted };
out.push_str(&run.text);
}
}
if matches!(n.kind, NodeKind::Text(_) | NodeKind::Path { .. } | NodeKind::Compound { .. })
let shows = !matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().all(|r| r.style.fill.is_none() && !r.style.has_stroke()));
if shows
&& 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_eq!((painted.as_str(), unpainted.as_str()), ("CA", "AB"), "{text_as:?}");
} else {
assert_eq!(unpainted, "", "{text_as:?}");
}
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:?}");
+44
View File
@@ -315,6 +315,50 @@ fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() {
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Subset"]);
}
/// Characters with neither fill nor stroke (a scanned page's recognized text, as a PDF opens) are
/// written as invisible text, in rendering mode 3: searchable, drawing nothing, and type without
/// paint again when the file opens. Compressed or not, in PDF/X-1a and PDF/A too (#2675).
#[test]
fn type_without_paint_is_written_as_invisible_text() {
let unpainted = CharStyle { fill: Paint::None, ..style(24.0) };
let d = doc(vec![
TextObject::point(Point::new(20.0, 60.0), "Recognized", unpainted),
TextObject::point(Point::new(20.0, 160.0), "Visible", style(24.0)),
]);
let r = pdf(&d, false);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let ops = String::from_utf8_lossy(&r.bytes).split_whitespace().collect::<Vec<_>>().join(" ");
assert!(ops.contains("3 Tr") && ops.contains("0 Tr"), "the invisible text in mode 3, the visible in mode 0");
let written = |v: serde_json::Value| {
let settings: PdfSettings = serde_json::from_value(v).unwrap();
export(&d, &PdfOptions { settings, created: Some(0), ..Default::default() }).unwrap()
};
let files = [
("uncompressed", r.bytes.clone()),
("compressed", written(json!({"advanced": {"outlineText": false}}))),
("PDF/X-1a", written(json!({"standard": "pdfX1a", "compatibility": "1.3", "advanced": {"outlineText": false}}))),
("PDF/A-2b", written(json!({"standard": "pdfA2b", "advanced": {"outlineText": false}}))),
];
for (what, bytes) in files {
let back = import_as(&bytes, TextAs::Text);
assert_eq!(texts(&back), ["Recognized", "Visible"], "{what}");
let mut paints = vec![];
back.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
paints.push(t.runs.iter().all(|r| r.style.fill.is_none() && !r.style.has_stroke()));
}
});
assert_eq!(paints, [true, false], "{what}: only the recognized text has no paint");
// It draws nothing: as outlines, only the visible text is there (its baseline on 160).
let drawn = ink(&import_as(&bytes, TextAs::Outlines));
assert!(drawn.y0 > 100.0, "{what}: {drawn:?}");
}
// Text exported as outlines has no invisible text: there is nothing to draw.
let outlined = import_as(&pdf(&d, true).bytes, TextAs::Text);
assert!(texts(&outlined).is_empty());
assert!(ink(&outlined).y0 > 100.0);
}
#[test]
fn pdf_a_takes_real_text() {
let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Archive", style(24.0))]);
+307
View File
@@ -0,0 +1,307 @@
//! How the host's generated dialogs show a plug-in's parameters: the optional `label`, `unit`,
//! `group`, `help`, `helpUrl` and `visibleWhen` keys of each one (docs/plugins.md › Parameters).
//!
//! These keys are presentation only. A host from before them ignores them, so a plug-in that
//! declares them still installs there, and a plug-in is given every parameter whether its dialog
//! showed it or not: a run never depends on what was on screen. The dialogs of filters, live
//! effects and exporters draw the parameters outside any group first, then each group as a section,
//! in the order the groups first appear, and leave out a parameter while its `visibleWhen` isn't met.
//!
//! `visibleWhen` names parameters declared before the one it belongs to, so the parameter that
//! decides comes first in the dialog and conditions can't go round in a circle. A parameter is shown
//! when each one it names is shown and has one of the values listed for it.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::manifest::{Manifest, Param, ParamSpec};
use crate::{Error, Result};
/// Longest `label` or `group`, in characters.
pub const MAX_LABEL: usize = 64;
/// Longest `unit`, in characters.
pub const MAX_UNIT: usize = 16;
/// Longest `help`, in characters.
pub const MAX_HELP: usize = 1_000;
/// Longest `helpUrl`, in bytes.
pub const MAX_HELP_URL: usize = 512;
/// Most parameters one `visibleWhen` names.
pub const MAX_CONDITIONS: usize = 8;
/// Most values a `visibleWhen` lists for one parameter.
pub const MAX_CONDITION_VALUES: usize = 64;
/// One section of a dialog ([`Manifest::sections`]): its group (`None`: the parameters outside any
/// group) and its parameters, in declaration order.
pub type Section<'a> = (Option<&'a str>, Vec<&'a (String, Param)>);
/// How dialogs show one parameter (every key optional).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParamUi {
/// The field's label; without one, the name spaced out (`rowSpacing` → "Row Spacing").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// The unit the value is in, shown after the field (the plug-in's own: nothing is converted).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
/// The section of the dialog the parameter is in.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
/// What the parameter does, shown when the pointer rests on its label.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
/// An `https://` page about the parameter, opened by the help button after its field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help_url: Option<String>,
/// Shown only while each parameter named here has one of the values listed for it.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub visible_when: BTreeMap<String, Vec<Value>>,
}
/// Is `s` 1 to `max` characters, not all spaces, without control characters (`\n` too, unless
/// `lines`)?
fn printable(s: &str, max: usize, lines: bool) -> bool {
!s.trim().is_empty() && s.chars().count() <= max && s.chars().all(|c| !c.is_control() || (lines && c == '\n'))
}
impl ParamUi {
/// Checks the keys of parameter `name`, the `index`th of manifest `m`'s parameters.
pub(crate) fn validate(&self, m: &Manifest, index: usize, name: &str) -> Result<()> {
let bad = |what: &str| Err(Error::Manifest(format!("parameter `{name}`: {what}")));
let text = |v: &Option<String>, max: usize, lines: bool| v.as_deref().is_none_or(|s| printable(s, max, lines));
if !text(&self.label, MAX_LABEL, false) || !text(&self.group, MAX_LABEL, false) {
return bad(&format!("`label` and `group` must be 1-{MAX_LABEL} printable characters"));
}
if !text(&self.unit, MAX_UNIT, false) {
return bad(&format!("`unit` must be 1-{MAX_UNIT} printable characters"));
}
if !text(&self.help, MAX_HELP, true) {
return bad(&format!("`help` must be 1-{MAX_HELP} printable characters"));
}
if let Some(url) = &self.help_url {
let rest = url.strip_prefix("https://").unwrap_or_default();
if rest.is_empty() || url.len() > MAX_HELP_URL || url.chars().any(|c| c.is_whitespace() || c.is_control()) {
return bad(&format!("`helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes"));
}
}
if self.visible_when.len() > MAX_CONDITIONS {
return bad(&format!("`visibleWhen` names at most {MAX_CONDITIONS} parameters"));
}
let before = m.params.get(..index).unwrap_or_default();
for (other, values) in &self.visible_when {
let Some((_, p)) = before.iter().find(|(k, _)| k == other) else {
return bad(&format!("`visibleWhen` names `{other}`, which isn't a parameter declared before this one"));
};
if values.is_empty() || values.len() > MAX_CONDITION_VALUES {
return bad(&format!("`visibleWhen` lists 1 to {MAX_CONDITION_VALUES} values for `{other}`"));
}
let fits = |v: &Value| match &p.spec {
ParamSpec::Bool { .. } => v.is_boolean(),
ParamSpec::Choice { options, .. } => v.as_str().is_some_and(|s| options.iter().any(|o| o == s)),
_ => false,
};
if !values.iter().all(fits) {
return bad(&format!("`visibleWhen` takes `{other}`'s values, of a checkbox or a choice"));
}
}
Ok(())
}
}
impl Manifest {
/// The names of the parameters shown while the dialog's values are `values` (missing ones at
/// their defaults): those whose `visibleWhen` names only shown parameters, each with one of the
/// values listed for it.
///
/// One pass in declaration order decides each parameter once, from the parameters before it.
/// Following the conditions back instead would revisit a parameter once per path to it, and
/// overlapping conditions have exponentially many. A condition on a parameter declared later,
/// which only a manifest built in code can have, hides the parameter, so conditions that go
/// round in a circle hide every parameter on it.
pub fn visible(&self, values: &Map<String, Value>) -> BTreeSet<&str> {
let mut shown = BTreeSet::new();
for (name, p) in &self.params {
let met = p.ui.visible_when.iter().all(|(other, allowed)| {
shown.contains(other.as_str())
&& match values.get(other) {
Some(v) => allowed.contains(v),
None => self.param(other).is_some_and(|o| allowed.contains(&o.spec.default_value())),
}
});
if met {
shown.insert(name.as_str());
}
}
shown
}
/// Is parameter `name` shown while the dialog's values are `values`? See [`Manifest::visible`],
/// which a dialog calls once for all its parameters.
pub fn shown(&self, name: &str, values: &Map<String, Value>) -> bool {
self.visible(values).contains(name)
}
/// The parameter named `name`.
pub fn param(&self, name: &str) -> Option<&Param> {
self.params.iter().find(|(k, _)| k == name).map(|(_, p)| p)
}
/// The dialog's sections in order: the parameters outside any group (`None`), then each group
/// in the order it first appears, each with its parameters in declaration order.
pub fn sections(&self) -> Vec<Section<'_>> {
let mut out: Vec<Section> = Vec::new();
let ungrouped: Vec<_> = self.params.iter().filter(|(_, p)| p.ui.group.is_none()).collect();
if !ungrouped.is_empty() {
out.push((None, ungrouped));
}
for entry in &self.params {
let Some(group) = entry.1.ui.group.as_deref() else { continue };
match out.iter_mut().find(|(g, _)| *g == Some(group)) {
Some((_, members)) => members.push(entry),
None => out.push((Some(group), vec![entry])),
}
}
out
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
const RICH: &str = r#"{"id":"org.example.hatch","name":"Hatch","kind":"effect","params":{
"pattern":{"type":"choice","options":["lines","crosshatch","dots"],"default":"lines",
"label":"Pattern","group":"Fill","help":"How the shape is filled."},
"spacing":{"type":"number","min":0.1,"max":10,"default":0.25,"label":"Spacing","unit":"mm","group":"Fill",
"help":"Distance between the lines.\nSmaller is denser.","helpUrl":"https://example.org/params/spacing",
"visibleWhen":{"pattern":["lines","crosshatch"]}},
"outline":{"type":"bool","default":true,"label":"Outline"},
"outline_width":{"type":"number","min":0,"max":10,"default":0.5,"unit":"pt","group":"Outline","visibleWhen":{"outline":[true]}},
"outline_passes":{"type":"int","min":1,"max":4,"default":1,"group":"Outline",
"visibleWhen":{"outline":[true],"pattern":["lines"]}},
"seed":{"type":"int","min":0,"max":9}}}"#;
fn rich() -> Manifest {
Manifest::parse(RICH.as_bytes()).unwrap()
}
#[test]
fn keys_parse_and_round_trip_with_the_parameters() {
let m = rich();
let spacing = m.param("spacing").unwrap();
assert_eq!(
(spacing.ui.label.as_deref(), spacing.ui.unit.as_deref(), spacing.ui.group.as_deref()),
(Some("Spacing"), Some("mm"), Some("Fill"))
);
assert_eq!(spacing.ui.help_url.as_deref(), Some("https://example.org/params/spacing"));
assert_eq!(spacing.spec, ParamSpec::Number { min: 0.1, max: 10.0, default: 0.25 });
assert_eq!(m.param("seed").unwrap().ui, ParamUi::default());
// Written out, the manifest reads back as it was, parameters in their order.
assert_eq!(Manifest::parse(&serde_json::to_vec(&m).unwrap()).unwrap(), m);
assert_eq!(serde_json::to_value(&m).unwrap()["params"]["seed"], json!({"type": "int", "min": 0, "max": 9, "default": 0}));
}
#[test]
fn sections_put_ungrouped_parameters_first_then_groups_as_they_appear() {
let m = rich();
let names: Vec<(Option<&str>, Vec<&str>)> =
m.sections().into_iter().map(|(g, ps)| (g, ps.into_iter().map(|(k, _)| k.as_str()).collect())).collect();
assert_eq!(
names,
[(None, vec!["outline", "seed"]), (Some("Fill"), vec!["pattern", "spacing"]), (Some("Outline"), vec!["outline_width", "outline_passes"])]
);
}
#[test]
fn visible_when_follows_the_values_and_the_parameters_it_names() {
let m = rich();
let shown = |values: Value| -> Vec<&str> {
let values = values.as_object().cloned().unwrap_or_default();
m.params.iter().filter(|(k, _)| m.shown(k, &values)).map(|(k, _)| k.as_str()).collect()
};
// At the defaults: lines, with an outline.
assert_eq!(shown(json!({})), ["pattern", "spacing", "outline", "outline_width", "outline_passes", "seed"]);
assert_eq!(shown(json!({"pattern": "dots"})), ["pattern", "outline", "outline_width", "seed"]);
assert_eq!(shown(json!({"outline": false})), ["pattern", "spacing", "outline", "seed"]);
assert!(!m.shown("no_such_parameter", &Map::new()));
}
/// A manifest built in code rather than parsed can name parameters in a circle: they are
/// hidden instead of recursing forever.
#[test]
fn conditions_in_a_circle_hide_their_parameters() {
let mut m = rich();
let outline = m.params.iter().position(|(k, _)| k == "outline").unwrap();
// `outline` names `outline_width`, which names `outline`.
m.params[outline].1.ui.visible_when.insert("outline_width".into(), vec![json!(0.5)]);
assert!(!m.shown("outline", &Map::new()) && !m.shown("outline_width", &Map::new()));
assert!(m.shown("seed", &Map::new()) && m.shown("pattern", &Map::new()));
}
/// Conditions that each name several earlier parameters are checked once per parameter, not
/// once per path through them: 256 checkboxes, each shown while the 8 before it are checked,
/// would otherwise take longer than the age of the universe on every frame of the dialog.
#[test]
fn overlapping_conditions_are_checked_once_per_parameter() {
let params: Vec<String> = (0..256)
.map(|i: usize| {
let when: Vec<String> = (i.saturating_sub(MAX_CONDITIONS)..i).map(|j| format!(r#""p{j}":[true]"#)).collect();
format!(r#""p{i}":{{"type":"bool","default":true,"group":"G","visibleWhen":{{{}}}}}"#, when.join(","))
})
.collect();
let m = Manifest::parse(format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{}}}}}"#, params.join(",")).as_bytes()).unwrap();
let (done, finished) = std::sync::mpsc::channel();
std::thread::spawn(move || done.send(m.visible(&Map::new()).len()));
let shown = finished.recv_timeout(std::time::Duration::from_secs(10)).expect("the check took more than 10 s");
assert_eq!(shown, 256, "every checkbox is checked, so every parameter is shown");
// Unchecking the first hides the rest: each names it or one it hides.
let m = Manifest::parse(format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{}}}}}"#, params.join(",")).as_bytes()).unwrap();
let values = json!({"p0": false}).as_object().cloned().unwrap();
assert_eq!(m.visible(&values).into_iter().collect::<Vec<_>>(), ["p0"]);
}
#[test]
fn bad_keys_are_refused() {
let with = |key: &str, value: &str| {
format!(
r#"{{"id":"a","name":"x","kind":"filter","params":{{"mode":{{"type":"choice","options":["a","b"]}},
"on":{{"type":"bool"}},"n":{{"type":"number","min":0,"max":1,"default":0,"{key}":{value}}}}}}}"#
)
};
let long = format!("\"{}\"", "x".repeat(MAX_LABEL + 1));
for (key, value) in [
("label", r#""""#),
("label", r#"" ""#),
("label", r#""a\u0007b""#),
("label", long.as_str()),
("group", r#""line\nbreak""#),
("unit", r#""millimetres and more""#),
("help", r#""""#),
("helpUrl", r#""http://example.org""#),
("helpUrl", r#""https://""#),
("helpUrl", r#""https://exa mple.org""#),
("helpUrl", r#""javascript:alert(1)""#),
("visibleWhen", r#"{"missing":[true]}"#),
("visibleWhen", r#"{"n":[0]}"#),
("visibleWhen", r#"{"on":["yes"]}"#),
("visibleWhen", r#"{"mode":["c"]}"#),
("visibleWhen", r#"{"mode":[]}"#),
] {
let manifest = with(key, value);
assert!(Manifest::parse(manifest.as_bytes()).is_err(), "{key}: {value}");
}
// A parameter declared after the one that names it, and one naming itself.
let later = r#"{"id":"a","name":"x","kind":"filter","params":{"n":{"type":"number","min":0,"max":1,"default":0,
"visibleWhen":{"on":[true]}},"on":{"type":"bool"}}}"#;
let itself = r#"{"id":"a","name":"x","kind":"filter","params":{"on":{"type":"bool","visibleWhen":{"on":[true]}}}}"#;
for bad in [later, itself] {
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
}
let fine = with("help", r#""Two\nlines.""#);
assert!(Manifest::parse(fine.as_bytes()).is_ok());
}
}
+92 -14
View File
@@ -1,23 +1,28 @@
//! Live-effect plug-ins: a plug-in of kind `effect` rewrites one object's geometry each time it is
//! drawn, from its appearance stack (an `Effect` record with id `plugin.<plug-in id>`).
//!
//! The plug-in is given the geometry and, read only, the object's fills and strokes as a filter
//! sees them ([`Paints`]): an effect can draw as wide as the stroke it sits on, or tell its fills
//! apart, without asking for them again as parameters.
//!
//! Drawing evaluates effects often (every frame, hit tests, bounds), so results are cached by
//! plug-in installation, parameters and input geometry: the plug-in runs again only when one of
//! them changes. Failures are cached too (the geometry is then drawn unchanged) and reported by
//! [`last_error`].
//! plug-in installation, parameters, input geometry and paints: the plug-in runs again only when
//! one of them changes. Failures are cached too (the geometry is then drawn unchanged) and
//! reported by [`last_error`].
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Mutex;
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, AppearanceItem};
use vectorcraft_geom::{FillRule, PathData, Rect};
use crate::objects::{decode, output_geometry};
use crate::objects::{decode, output_geometry, paint_lists};
use crate::{Error, Kind, Limits, Result, registry};
/// The effect id prefix of live-effect plug-ins: `plugin.<plug-in id>`.
pub const PREFIX: &str = "plugin.";
pub const PREFIX: &str = crate::PREFIX;
/// Wall-clock budget of one live-effect run (drawing waits for it), in milliseconds.
pub const WALL_TIME_MS: u64 = 5_000;
@@ -40,10 +45,47 @@ pub fn installed(effect_id: &str) -> Option<std::sync::Arc<crate::Plugin>> {
registry::get(plugin_id(effect_id)?).filter(|p| p.manifest().kind == Kind::Effect)
}
/// What a live effect is given besides its geometry: the object's appearance and where in it the
/// effect sits.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Paints<'a> {
/// The object's fills and strokes (`None`: an object without, given as empty lists).
pub appearance: Option<&'a Appearance>,
/// The index in `appearance.items` of the fill or stroke whose own effects hold this one;
/// `None`: one of the object's effects.
pub item: Option<usize>,
}
/// [`Paints`] as the plug-in is given them: the input object's `fills` and `strokes`, and
/// `_context.item`.
struct Given {
fills: Vec<Value>,
strokes: Vec<Value>,
item: Value,
}
impl Paints<'_> {
/// What the plug-in is given: `item` is `"object"`, or `{"fill": i}` / `{"stroke": i}` with `i`
/// counting the object's fills or strokes from the bottom.
fn given(&self) -> Given {
let Some(ap) = self.appearance else { return Given { fills: Vec::new(), strokes: Vec::new(), item: json!("object") } };
let (fills, strokes) = paint_lists(ap);
let at = self.item.and_then(|i| Some((ap.items.get(i)?, ap.items.get(..i)?)));
let item = match at {
Some((AppearanceItem::Fill(_), below)) => json!({"fill": below.iter().filter(|i| i.is_fill()).count()}),
Some((AppearanceItem::Stroke(_), below)) => json!({"stroke": below.iter().filter(|i| !i.is_fill()).count()}),
None => json!("object"),
};
Given { fills, strokes, item }
}
}
#[derive(PartialEq)]
struct Key {
serial: u64,
params: String,
/// The paints and `_context.item`, as the plug-in is given them.
paints: String,
bounds: [u64; 4],
rule: FillRule,
path: PathData,
@@ -52,7 +94,7 @@ struct Key {
impl Key {
fn hash64(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
(self.serial, &self.params, self.bounds, self.rule).hash(&mut h);
(self.serial, &self.params, &self.paints, self.bounds, self.rule).hash(&mut h);
for sp in &self.path.subpaths {
sp.closed.hash(&mut h);
for a in &sp.anchors {
@@ -84,15 +126,17 @@ fn note(id: &str, r: &Result<PathData>) {
};
}
/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`): the new geometry,
/// or `None` when the plug-in isn't installed, isn't an effect, or fails (the caller then keeps
/// the geometry).
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule) -> Option<PathData> {
/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`), of an object painted
/// with `paints`: the new geometry, or `None` when the plug-in isn't installed, isn't an effect, or
/// fails (the caller then keeps the geometry).
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<PathData> {
let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?;
let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?);
let given = paints.given();
let key = Key {
serial: plugin.serial(),
params: resolved.to_string(),
paints: json!([given.fills, given.strokes, given.item]).to_string(),
bounds: [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(f64::to_bits),
rule,
path: path.clone(),
@@ -103,7 +147,12 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
{
return out.clone();
}
let r = run(&plugin, resolved, path, bounds, rule);
let r = run(&plugin, resolved, path, bounds, rule, given);
// A drawing that was cancelled (an export stopped half way) says nothing about the plug-in:
// the next drawing runs it again.
if matches!(r, Err(Error::Cancelled)) {
return None;
}
note(id, &r);
let out = r.ok();
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
@@ -115,16 +164,45 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill
out
}
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule) -> Result<PathData> {
/// One run of `plugin` on `path`, with the object's paints as `given`.
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given) -> Result<PathData> {
let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]);
params["_context"] = json!({"mode": "effect", "bounds": b});
params["_context"] = json!({"mode": "effect", "bounds": b, "item": given.item});
let rule = match rule {
FillRule::NonZero => "nonzero",
FillRule::EvenOdd => "evenodd",
};
let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b}]});
let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b, "fills": given.fills, "strokes": given.strokes}]});
let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(&params));
let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) };
let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() };
Ok(output_geometry(decode(&plugin.run_limited(&input, &params, &limits)?)?))
}
#[cfg(test)]
mod tests {
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{FillLayer, StrokeLayer};
use super::*;
/// Fills and strokes come in paint order, and the item an effect sits on is counted among the
/// fills or among the strokes.
#[test]
fn the_paints_and_the_item_given() {
let fill = |c| AppearanceItem::Fill(FillLayer::new(Paint::solid(c)));
let stroke = |w| AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), w));
let ap = Appearance { items: vec![fill(Color::WHITE), stroke(2.0), fill(Color::BLACK), stroke(5.0)], ..Appearance::default() };
let given = |item| Paints { appearance: Some(&ap), item }.given();
let g = given(None);
assert_eq!((g.fills.len(), g.strokes.len(), &g.item), (2, 2, &json!("object")));
assert_eq!(g.strokes.iter().map(|s| s["width"].clone()).collect::<Vec<_>>(), [json!(2.0), json!(5.0)]);
assert_eq!(g.fills[0], serde_json::to_value(Paint::solid(Color::WHITE)).unwrap());
assert_eq!(given(Some(3)).item, json!({"stroke": 1}));
assert_eq!(given(Some(2)).item, json!({"fill": 1}));
assert_eq!(given(Some(0)).item, json!({"fill": 0}));
assert_eq!(given(Some(9)).item, json!("object"), "no such item");
let none = Paints::default().given();
assert_eq!((none.fills, none.strokes, none.item), (vec![], vec![], json!("object")));
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Exporter plug-ins: a plug-in of kind `exporter` writes the document in a file format of its
//! own, such as a pen plotter's or a cutting machine's.
//!
//! File › Export › Export As lists each installed exporter after the built-in formats, under its
//! name, and `document.export` writes it by its format id (`plugin.<id>`) or by one of the
//! extensions its manifest lists. A built-in format keeps its extension: an exporter that lists
//! `svg` too is picked by its format id. The host hands the exporter the document as the native
//! file's JSON (`vectorcraft_format::json_part`: images of `INLINE_MAX` bytes or more without
//! their data) and writes the bytes it returns.
//!
//! An export is one run the user asked for, often of a whole document, so it gets a larger budget
//! than a filter: [`FUEL`] instructions, plus [`Limits::fuel_per_byte`] for each byte of input,
//! and [`WALL_TIME_MS`]. It runs where the export runs: with Background Export on the export's
//! worker, whose Cancel stops it between two slices of the budget ([`Error::Cancelled`]).
use std::sync::Arc;
use serde_json::{Value, json};
use crate::objects::{declined, printable};
use crate::{Error, Kind, Limits, Plugin, Result, registry};
/// Instructions an export may use, besides [`Limits::fuel_per_byte`] for each byte of the
/// document: 200 times a filter's base budget.
pub const FUEL: u64 = 10_000_000_000;
/// Wall-clock budget of one export, in milliseconds (native builds; the web build has no clock,
/// and the instruction budget alone bounds it there).
pub const WALL_TIME_MS: u64 = 300_000;
/// Most warnings an export reports.
pub const MAX_WARNINGS: usize = 100;
/// Longest warning, in characters.
pub const MAX_WARNING_CHARS: usize = 500;
/// The format id of exporter `plugin_id`: `plugin.<id>`.
pub fn format_id(plugin_id: &str) -> String {
format!("{}{plugin_id}", crate::PREFIX)
}
/// The plug-in id in format id `format_id`.
pub fn plugin_id(format_id: &str) -> Option<&str> {
format_id.strip_prefix(crate::PREFIX)
}
/// The installed exporters, by plug-in id.
pub fn installed() -> Vec<Arc<Plugin>> {
registry::list().into_iter().filter(|p| p.manifest().kind == Kind::Exporter).collect()
}
/// The installed exporter of format id `format_id`.
pub fn by_format(format_id: &str) -> Option<Arc<Plugin>> {
registry::get(plugin_id(format_id)?).filter(|p| p.manifest().kind == Kind::Exporter)
}
/// What an exporter returned.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Exported {
/// The file, base64 encoded, as the plug-in returned it (the host decodes it: this crate has
/// no base64 of its own).
pub data_base64: String,
/// What the export left out or changed, for the user (at most [`MAX_WARNINGS`]).
pub warnings: Vec<String>,
}
/// The file an export writes, as the exporter is told in `_context`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Target<'a> {
/// Its extension, without the dot: one the exporter lists.
pub extension: &'a str,
/// Its name without the extension (some formats keep a name in their header).
pub name: &'a str,
}
/// The limits of an export by `plugin`: its own, with the budgets of an export.
pub fn limits(plugin: &Plugin) -> Limits {
Limits { fuel_base: FUEL, wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }
}
/// Runs exporter `plugin` on `document`, the native file's JSON, with `params` (checked against
/// its manifest: missing ones take their defaults) for the file `target`.
pub fn run(plugin: &Plugin, document: &[u8], params: &Value, target: Target) -> Result<Exported> {
let m = plugin.manifest();
if m.kind != Kind::Exporter {
return Err(Error::Params(format!("`{}` is a {}, not an exporter", m.id, m.kind.name())));
}
let mut params = Value::Object(m.resolve_params(params)?);
params["_context"] = json!({"mode": "export", "extension": target.extension, "name": target.name});
let params = serde_json::to_vec(&params).map_err(|e| Error::Params(e.to_string()))?;
decode(&plugin.run_limited(document, &params, &limits(plugin))?)
}
/// An exporter's output: `{"dataBase64": "…", "warnings"?: ["…"]}`, or `{"error": "…"}` to decline
/// with a message for the user.
pub fn decode(bytes: &[u8]) -> Result<Exported> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
if let Some(e) = declined(&v) {
return Err(e);
}
let data = v.get("dataBase64").and_then(Value::as_str).ok_or_else(|| Error::Output("expected {\"dataBase64\": \"…\"}".into()))?;
let warnings = match v.get("warnings") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(w)) => w.iter().take(MAX_WARNINGS).map(|w| printable(w, MAX_WARNING_CHARS)).collect(),
Some(_) => return Err(Error::Output("`warnings` must be a list of strings".into())),
};
Ok(Exported { data_base64: data.to_string(), warnings })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_ids_name_the_plug_in() {
assert_eq!(format_id("org.example.plotter"), "plugin.org.example.plotter");
assert_eq!(plugin_id("plugin.org.example.plotter"), Some("org.example.plotter"));
assert_eq!(plugin_id("png"), None);
}
#[test]
fn output_is_the_data_and_the_warnings_or_a_decline() {
let out = decode(br#"{"dataBase64": "SGk=", "warnings": ["a", 2]}"#).unwrap();
assert_eq!(out, Exported { data_base64: "SGk=".into(), warnings: vec!["a".into(), "2".into()] });
assert_eq!(decode(br#"{"dataBase64": ""}"#).unwrap().warnings, Vec::<String>::new());
assert_eq!(decode(br#"{"error": "Nothing to\nwrite."}"#), Err(Error::Failed("Nothing towrite.".into())));
for bad in [&b"[]"[..], b"{}", b"not json", br#"{"dataBase64": 1}"#, br#"{"dataBase64": "", "warnings": "x"}"#] {
assert!(matches!(decode(bad), Err(Error::Output(_))), "{}", String::from_utf8_lossy(bad));
}
}
#[test]
fn warnings_are_capped_in_number_and_length() {
let many: Vec<String> = (0..MAX_WARNINGS + 5).map(|i| format!("w{i}")).collect();
let long = "x".repeat(MAX_WARNING_CHARS + 1);
let out = decode(json!({"dataBase64": "", "warnings": many}).to_string().as_bytes()).unwrap();
assert_eq!(out.warnings.len(), MAX_WARNINGS);
let out = decode(json!({"dataBase64": "", "warnings": [long]}).to_string().as_bytes()).unwrap();
assert_eq!(out.warnings.first().map(|w| w.chars().count()), Some(MAX_WARNING_CHARS));
}
}
+16 -3
View File
@@ -17,20 +17,29 @@
//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns
//! the objects to keep, change or add, applied to the document as one edit
//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each
//! time it is drawn, from an appearance stack. The process-wide [`registry`] holds the installed
//! plug-ins.
//! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole
//! document in a file format of its own. The process-wide [`registry`] holds the installed
//! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show
//! them well: labels, units, groups, help and conditions ([`dialog`]).
#![forbid(unsafe_code)]
pub mod dialog;
pub mod effect;
pub mod export;
pub mod manifest;
pub mod objects;
pub mod registry;
mod runtime;
pub mod wat;
pub use manifest::{Kind, Manifest, ParamSpec};
pub use dialog::ParamUi;
pub use manifest::{Kind, Manifest, Param, ParamSpec};
pub use runtime::{ABI_VERSION, Limits, Plugin};
/// What the host puts before a plug-in's id to name what it adds: its live effect
/// (`plugin.<id>`, [`effect`]) or its file format ([`export`]).
pub const PREFIX: &str = "plugin.";
/// A plug-in error. Every way a module can misbehave ends here, never in a panic.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
@@ -52,6 +61,10 @@ pub enum Error {
Failed(String),
#[error("no plug-in {0:?} is installed")]
NotFound(String),
/// The caller asked the run to stop ([`vectorcraft_doc::cancel`]), between two slices of
/// its instruction budget.
#[error("cancelled")]
Cancelled,
#[error("{0}")]
Io(String),
}
+222 -30
View File
@@ -1,14 +1,24 @@
//! The plug-in manifest (`vc_manifest`) and parameter schema.
//! The plug-in manifest (`vc_manifest`) and parameter schema: what each parameter holds
//! ([`ParamSpec`]) and how dialogs show it ([`ParamUi`], in [`crate::dialog`]).
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::dialog::ParamUi;
use crate::{Error, Result};
/// Longest manifest the host reads.
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
/// Most parameters a plug-in may declare.
pub const MAX_PARAMS: usize = 64;
pub const MAX_PARAMS: usize = 256;
/// Most parameters outside groups (`group`): a dialog of more is sorted into sections.
pub const MAX_UNGROUPED: usize = 64;
/// Longest text a `string` parameter may hold, in characters.
pub const MAX_TEXT: usize = 4_096;
/// Most numbers a `numberList` parameter may hold.
pub const MAX_LIST: usize = 256;
/// Most file extensions an exporter may declare.
pub const MAX_EXTENSIONS: usize = 8;
/// What a plug-in is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -20,6 +30,9 @@ pub enum Kind {
/// A live effect: rewrites one object's geometry each time it is drawn, from its appearance
/// stack (Effect › Plug-ins, `effect.apply`).
Effect,
/// An exporter: writes the document in a file format of its own, whose extensions the manifest
/// lists (File › Export › Export As, `document.export`; see [`crate::export`]).
Exporter,
}
impl Kind {
@@ -27,6 +40,7 @@ impl Kind {
match self {
Kind::Filter => "filter",
Kind::Effect => "effect",
Kind::Exporter => "exporter",
}
}
}
@@ -57,6 +71,27 @@ pub enum ParamSpec {
#[serde(default)]
default: Option<String>,
},
/// Text of at most `max_length` characters (a text field).
#[serde(rename = "string")]
Text {
#[serde(default)]
default: String,
#[serde(default = "text_max", rename = "maxLength")]
max_length: usize,
},
/// `min_items` to `max_items` numbers, each in `min..=max` (a field of numbers separated by
/// commas or spaces).
#[serde(rename_all = "camelCase")]
NumberList {
min: f64,
max: f64,
#[serde(default)]
default: Vec<f64>,
#[serde(default)]
min_items: usize,
#[serde(default = "list_max")]
max_items: usize,
},
}
fn int_min() -> i64 {
@@ -65,6 +100,28 @@ fn int_min() -> i64 {
fn int_max() -> i64 {
1_000_000
}
fn text_max() -> usize {
256
}
fn list_max() -> usize {
16
}
/// One declared parameter: what it holds, and how dialogs show it. In the manifest both are keys of
/// the same object: `{"type": "number", "min": 0, "max": 10, "default": 1, "unit": "mm"}`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Param {
#[serde(flatten)]
pub spec: ParamSpec,
#[serde(flatten)]
pub ui: ParamUi,
}
impl From<ParamSpec> for Param {
fn from(spec: ParamSpec) -> Self {
Param { spec, ui: ParamUi::default() }
}
}
impl ParamSpec {
/// The value a parameter takes when it isn't given (numeric defaults clamped into range).
@@ -74,6 +131,40 @@ impl ParamSpec {
ParamSpec::Int { min, max, default } => json!((*default).clamp(*min, *max)),
ParamSpec::Bool { default } => json!(default),
ParamSpec::Choice { options, default } => json!(default.clone().or_else(|| options.first().cloned()).unwrap_or_default()),
ParamSpec::Text { default, max_length } => json!(default.chars().take(*max_length).collect::<String>()),
ParamSpec::NumberList { min, max, default, .. } => json!(default.iter().map(|x| x.clamp(*min, *max)).collect::<Vec<f64>>()),
}
}
/// Checks the spec of parameter `k`.
fn validate(&self, k: &str) -> Result<()> {
let bad = |what: &str| Err(Error::Manifest(format!("parameter `{k}`: {what}")));
match self {
ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => bad("bad range"),
ParamSpec::Int { min, max, .. } if min > max => bad("bad range"),
ParamSpec::Choice { options, default } => {
if options.is_empty() || options.len() > 64 || options.iter().any(|o| o.is_empty() || o.len() > 64 || o.chars().any(char::is_control))
{
return bad("choices must be 1-64 non-empty, printable strings");
}
if default.as_ref().is_some_and(|d| !options.contains(d)) {
return bad("default is not one of the options");
}
Ok(())
}
ParamSpec::Text { default, max_length } if !(1..=MAX_TEXT).contains(max_length) || default.chars().count() > *max_length => {
bad(&format!("`maxLength` must be 1-{MAX_TEXT}, and the default no longer"))
}
ParamSpec::NumberList { min, max, default, min_items, max_items } => {
if !(min.is_finite() && max.is_finite()) || min > max || default.iter().any(|x| !x.is_finite()) {
return bad("bad range");
}
if !(1..=MAX_LIST).contains(max_items) || min_items > max_items || !(*min_items..=*max_items).contains(&default.len()) {
return bad(&format!("`minItems` and `maxItems` must be 0-{MAX_LIST}, `maxItems` at least 1, and the default that many"));
}
Ok(())
}
_ => Ok(()),
}
}
}
@@ -95,7 +186,11 @@ pub struct Manifest {
pub author: String,
/// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest.
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
pub params: Vec<(String, ParamSpec)>,
pub params: Vec<(String, Param)>,
/// An exporter's file extensions, lower case and without the dot; the first is the one its
/// files get. Only exporters declare them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
}
impl Manifest {
@@ -120,6 +215,7 @@ impl Manifest {
description: String::new(),
author: String::new(),
params: Vec::new(),
extensions: Vec::new(),
}
}
@@ -134,27 +230,38 @@ impl Manifest {
if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 {
return Err(Error::Manifest("`version`, `description` or `author` is too long".into()));
}
for (k, spec) in &self.params {
self.validate_extensions()?;
for (i, (k, p)) in self.params.iter().enumerate() {
if !valid_key(k) {
return Err(Error::Manifest(format!("invalid parameter name {k:?}")));
}
match spec {
ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => {
return Err(Error::Manifest(format!("parameter `{k}`: bad range")));
}
ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))),
ParamSpec::Choice { options, default } => {
if options.is_empty()
|| options.len() > 64
|| options.iter().any(|o| o.is_empty() || o.len() > 64 || o.chars().any(char::is_control))
{
return Err(Error::Manifest(format!("parameter `{k}`: choices must be 1-64 non-empty, printable strings")));
}
if default.as_ref().is_some_and(|d| !options.contains(d)) {
return Err(Error::Manifest(format!("parameter `{k}`: default is not one of the options")));
}
}
_ => {}
p.spec.validate(k)?;
p.ui.validate(self, i, k)?;
}
if self.params.iter().filter(|(_, p)| p.ui.group.is_none()).count() > MAX_UNGROUPED {
return Err(Error::Manifest(format!("at most {MAX_UNGROUPED} parameters outside groups: give the others a `group`")));
}
Ok(())
}
/// An exporter lists 1 to [`MAX_EXTENSIONS`] distinct extensions of 1 to 16 lower-case
/// letters and digits; other kinds list none.
fn validate_extensions(&self) -> Result<()> {
let exts = &self.extensions;
match self.kind {
Kind::Exporter => {}
_ if exts.is_empty() => return Ok(()),
_ => return Err(Error::Manifest("only an exporter declares `extensions`".into())),
}
if exts.is_empty() || exts.len() > MAX_EXTENSIONS {
return Err(Error::Manifest(format!("an exporter declares 1 to {MAX_EXTENSIONS} `extensions`")));
}
for (i, e) in exts.iter().enumerate() {
if !(1..=16).contains(&e.len()) || !e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) {
return Err(Error::Manifest(format!("extension {e:?}: use 1-16 lower-case letters and digits, without the dot")));
}
if exts.get(..i).is_some_and(|before| before.contains(e)) {
return Err(Error::Manifest(format!("extension {e:?} is listed twice")));
}
}
Ok(())
@@ -166,13 +273,17 @@ impl Manifest {
let parts: Vec<String> = self
.params
.iter()
.map(|(k, spec)| match spec {
.map(|(k, p)| match &p.spec {
ParamSpec::Number { min, max, default } => format!("{k}: {min}..{max} ({})", default.clamp(*min, *max)),
ParamSpec::Int { min, max, default } => format!("{k}: int {min}..{max} ({})", (*default).clamp(*min, *max)),
ParamSpec::Bool { default } => format!("{k}: bool ({default})"),
ParamSpec::Choice { options, .. } => {
let opts: Vec<String> = options.iter().map(|o| format!("{o:?}")).collect();
format!("{k}: {} ({})", opts.join("|"), spec.default_value().as_str().unwrap_or_default())
format!("{k}: {} ({})", opts.join("|"), p.spec.default_value().as_str().unwrap_or_default())
}
ParamSpec::Text { max_length, .. } => format!("{k}: string of at most {max_length} ({})", p.spec.default_value()),
ParamSpec::NumberList { min, max, min_items, max_items, .. } => {
format!("{k}: {min_items} to {max_items} numbers {min}..{max} ({})", p.spec.default_value())
}
})
.collect();
@@ -181,7 +292,7 @@ impl Manifest {
/// Every parameter at its default value.
pub fn defaults(&self) -> Map<String, Value> {
self.params.iter().map(|(k, spec)| (k.clone(), spec.default_value())).collect()
self.params.iter().map(|(k, p)| (k.clone(), p.spec.default_value())).collect()
}
/// Validates user `params` against the schema: missing keys take their defaults, numbers are
@@ -194,7 +305,8 @@ impl Manifest {
_ => return Err(Error::Params("plug-in parameters must be a JSON object".into())),
};
let mut out = Map::new();
for (k, spec) in &self.params {
for (k, p) in &self.params {
let spec = &p.spec;
let Some(v) = given.get(k).filter(|v| !v.is_null()) else {
out.insert(k.clone(), spec.default_value());
continue;
@@ -223,6 +335,17 @@ impl Manifest {
}
json!(s)
}
ParamSpec::Text { max_length, .. } => {
json!(v.as_str().ok_or_else(|| wrong("a string"))?.chars().take(*max_length).collect::<String>())
}
ParamSpec::NumberList { min, max, min_items, max_items, .. } => {
let items = v.as_array().ok_or_else(|| wrong("a list of numbers"))?;
if !(*min_items..=*max_items).contains(&items.len()) {
return Err(wrong(&format!("a list of {min_items} to {max_items} numbers")));
}
let numbers: Option<Vec<f64>> = items.iter().map(|x| x.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(*min, *max))).collect();
json!(numbers.ok_or_else(|| wrong("a list of numbers"))?)
}
};
out.insert(k.clone(), resolved);
}
@@ -231,10 +354,10 @@ impl Manifest {
}
/// Reads the `params` object keeping its key order (serde_json's `Map` would sort it).
fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<(String, ParamSpec)>, D::Error> {
fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<(String, Param)>, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = Vec<(String, ParamSpec)>;
type Value = Vec<(String, Param)>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an object of parameter specs")
}
@@ -242,8 +365,8 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result
Ok(Vec::new())
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
let mut out: Vec<(String, ParamSpec)> = Vec::new();
while let Some((k, v)) = map.next_entry::<String, ParamSpec>()? {
let mut out: Vec<(String, Param)> = Vec::new();
while let Some((k, v)) = map.next_entry::<String, Param>()? {
if out.len() >= MAX_PARAMS {
return Err(serde::de::Error::custom(format!("too many parameters (limit {MAX_PARAMS})")));
}
@@ -258,7 +381,7 @@ fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result
d.deserialize_any(V)
}
fn params_as_object<S: serde::Serializer>(p: &[(String, ParamSpec)], s: S) -> std::result::Result<S::Ok, S::Error> {
fn params_as_object<S: serde::Serializer>(p: &[(String, Param)], s: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = s.serialize_map(Some(p.len()))?;
for (k, v) in p {
@@ -355,6 +478,68 @@ mod tests {
}
}
#[test]
fn an_exporter_lists_its_extensions_and_other_kinds_list_none() {
let m = Manifest::parse(br#"{"id":"org.example.plotter","name":"Plotter","kind":"exporter","extensions":["plt","plot"]}"#).unwrap();
assert_eq!((m.kind, m.extensions.as_slice()), (Kind::Exporter, &["plt".to_string(), "plot".to_string()][..]));
assert_eq!(serde_json::to_value(&m).unwrap()["extensions"], json!(["plt", "plot"]));
let filter = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter"}"#).unwrap();
assert!(serde_json::to_value(&filter).unwrap().get("extensions").is_none());
for bad in [
r#"{"id":"a","name":"x","kind":"exporter"}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":[]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":["PLT"]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":[".plt"]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":[""]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":["abcdefghijklmnopq"]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt","plt"]}"#,
r#"{"id":"a","name":"x","kind":"exporter","extensions":["a","b","c","d","e","f","g","h","i"]}"#,
r#"{"id":"a","name":"x","kind":"filter","extensions":["plt"]}"#,
r#"{"id":"a","name":"x","kind":"effect","extensions":["plt"]}"#,
] {
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
}
}
#[test]
fn strings_and_number_lists_resolve_like_the_other_types() {
let m = Manifest::parse(
br#"{"id":"org.example.text","name":"Text","kind":"filter","params":{
"label":{"type":"string","default":"Rose","maxLength":8},
"angles":{"type":"numberList","min":0,"max":360,"default":[45,135],"minItems":1,"maxItems":3},
"free":{"type":"string"}}}"#,
)
.unwrap();
assert_eq!(Value::Object(m.defaults()), json!({"label": "Rose", "angles": [45.0, 135.0], "free": ""}));
let p = m.resolve_params(&json!({"label": "Sunflowers", "angles": [-10, 90, 400]})).unwrap();
assert_eq!((&p["label"], &p["angles"]), (&json!("Sunflowe"), &json!([0.0, 90.0, 360.0])), "cut and clamped");
for bad in [json!({"label": 3}), json!({"angles": []}), json!({"angles": [1, 2, 3, 4]}), json!({"angles": [1, "x"]}), json!({"angles": 5})] {
assert!(m.resolve_params(&bad).is_err(), "{bad}");
}
assert_eq!(
m.params_doc(),
r#"{label: string of at most 8 ("Rose"), angles: 1 to 3 numbers 0..360 ([45.0,135.0]), free: string of at most 256 ("")}"#
);
}
#[test]
fn up_to_256_parameters_with_at_most_64_outside_groups() {
let manifest = |n: usize, grouped: usize| {
let params: Vec<String> = (0..n)
.map(|i| match i < grouped {
true => format!(r#""p{i}":{{"type":"bool","group":"G{}"}}"#, i % 4),
false => format!(r#""p{i}":{{"type":"bool"}}"#),
})
.collect();
format!(r#"{{"id":"a","name":"x","kind":"filter","params":{{{}}}}}"#, params.join(","))
};
assert!(Manifest::parse(manifest(MAX_UNGROUPED, 0).as_bytes()).is_ok());
assert!(Manifest::parse(manifest(MAX_UNGROUPED + 1, 0).as_bytes()).is_err(), "too many outside groups");
let all = Manifest::parse(manifest(MAX_PARAMS, MAX_PARAMS - MAX_UNGROUPED).as_bytes()).unwrap();
assert_eq!(all.params.len(), MAX_PARAMS);
assert!(Manifest::parse(manifest(MAX_PARAMS + 1, MAX_PARAMS).as_bytes()).is_err(), "too many in all");
}
#[test]
fn rejects_bad_manifests() {
for bad in [
@@ -372,6 +557,13 @@ mod tests {
r#"{"id":"a","name":"x","kind":"filter","params":{"preview":{"type":"bool"}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"a":{"type":"bool"},"a":{"type":"bool"}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"t":{"type":"string","maxLength":0}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"t":{"type":"string","default":"abc","maxLength":2}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":1,"max":0}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"maxItems":0}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"maxItems":257}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"minItems":2,"maxItems":1}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"l":{"type":"numberList","min":0,"max":1,"minItems":2,"default":[1]}}}"#,
] {
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
}
+29 -11
View File
@@ -83,18 +83,23 @@ pub fn geometry(n: &Node) -> Option<(PathData, FillRule, bool)> {
}
}
/// A path or compound path as plug-ins see it (`None` for other objects): `{id, type, name?, path,
/// fillRule, fills, strokes, opacity, bounds}`.
pub fn encode(n: &Node) -> Option<Value> {
let (path, rule, compound) = geometry(n)?;
let fills: Vec<Value> =
n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(paint_json(&f.paint)) } else { None }).collect();
let strokes: Vec<Value> = n
.appearance
/// The fills and strokes of appearance `ap` as plug-ins see them, each in paint order: a fill is
/// its paint, a stroke `{paint, width}`. Filters and live effects are given the same.
pub(crate) fn paint_lists(ap: &Appearance) -> (Vec<Value>, Vec<Value>) {
let fills = ap.items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(paint_json(&f.paint)) } else { None }).collect();
let strokes = ap
.items
.iter()
.filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(json!({"paint": paint_json(&s.paint), "width": s.width})) } else { None })
.collect();
(fills, strokes)
}
/// A path or compound path as plug-ins see it (`None` for other objects): `{id, type, name?, path,
/// fillRule, fills, strokes, opacity, bounds}`.
pub fn encode(n: &Node) -> Option<Value> {
let (path, rule, compound) = geometry(n)?;
let (fills, strokes) = paint_lists(&n.appearance);
let mut o = json!({
"id": n.id.0, "type": if compound { "compound" } else { "path" }, "path": path, "fillRule": rule_name(rule),
"fills": fills, "strokes": strokes, "opacity": n.opacity, "bounds": rect_json(path.bounds()),
@@ -286,9 +291,8 @@ fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject>
/// declined (the message reaches the user).
pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
if let Some(e) = v.get("error").filter(|e| !e.is_null()) {
let msg: String = e.as_str().map_or_else(|| e.to_string(), str::to_string).chars().filter(|c| !c.is_control()).take(300).collect();
return Err(Error::Failed(msg));
if let Some(e) = declined(&v) {
return Err(e);
}
let objects = v.get("objects").and_then(Value::as_array).ok_or_else(|| Error::Output("expected {\"objects\": [...]}".into()))?;
if objects.len() > MAX_OBJECTS {
@@ -298,6 +302,20 @@ pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> {
objects.iter().enumerate().map(|(i, o)| decode_object(o, &format!("objects[{i}]"), &mut anchors)).collect()
}
/// The decline in a plug-in's output `v`, `{"error": "…"}`: its message as the user reads it,
/// without control characters and at most 300 of them.
pub(crate) fn declined(v: &Value) -> Option<Error> {
let e = v.get("error").filter(|e| !e.is_null())?;
Some(Error::Failed(printable(e, 300)))
}
/// A plug-in's text the user reads: `v` as a string (any other JSON as its text), without control
/// characters, at most `max` characters of it.
pub(crate) fn printable(v: &Value, max: usize) -> String {
let s = v.as_str().map_or_else(|| v.to_string(), str::to_string);
s.chars().filter(|c| !c.is_control()).take(max).collect()
}
/// The paints of `ap`'s fills (`fill`) or strokes, in paint order.
fn paints(ap: &Appearance, fill: bool) -> Vec<&Paint> {
ap.items.iter().filter(|i| i.is_fill() == fill).map(AppearanceItem::paint).collect()
+3 -1
View File
@@ -206,7 +206,8 @@ impl Plugin {
Ok(Live { store, instance, memory })
}
/// Calls `f` with at most `fuel` instructions, in slices, checking the deadline between them.
/// Calls `f` with at most `fuel` instructions, in slices, checking the deadline and the
/// caller's cancellation ([`vectorcraft_doc::cancel`]) between them.
fn call<P: WasmParams, R: WasmResults>(&self, live: &mut Live, f: &TypedFunc<P, R>, params: P, fuel: u64, deadline: &Deadline) -> Result<R> {
let mut left = fuel;
let give = left.min(FUEL_SLICE);
@@ -223,6 +224,7 @@ impl Plugin {
return Err(Error::Limit("instruction budget".into()));
}
deadline.check()?;
vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?;
let give = left.min(FUEL_SLICE.max(need));
left -= give;
live.store.set_fuel(give).map_err(wasm_err)?;
+125
View File
@@ -41,7 +41,132 @@ pub fn returning(manifest: &str, output: &str) -> String {
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
}
/// What [`when_given`] looks through.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Given {
/// The input JSON (the objects).
Input,
/// The parameters JSON, `_context` included.
Params,
}
/// Where [`when_given`] keeps the text it looks for.
const NEEDLE_AT: usize = 0x8000;
/// A plug-in that returns `output` when what it is given (`given`) holds the text `needle`, and
/// its input unchanged otherwise. A live effect made from it draws one shape or the other, so a
/// test sees from the geometry whether the host handed the plug-in something.
pub fn when_given(manifest: &str, given: Given, needle: &str, output: &str) -> String {
let (src, len) = match given {
Given::Input => ("$in", "$il"),
Given::Params => ("$pp", "$pl"),
};
let n = needle.len();
// For each place `$i` the needle fits, compare it byte by byte (`$j`).
let run = format!(
r#"(local $i i32) (local $j i32)
(block $found
(block $none
(loop $at
(br_if $none (i32.gt_u (i32.add (local.get $i) (i32.const {n})) (local.get {len})))
(local.set $j (i32.const 0))
(block $next
(loop $cmp
(br_if $found (i32.eq (local.get $j) (i32.const {n})))
(br_if $next (i32.ne (i32.load8_u (i32.add (local.get {src}) (i32.add (local.get $i) (local.get $j))))
(i32.load8_u (i32.add (i32.const {NEEDLE_AT}) (local.get $j)))))
(local.set $j (i32.add (local.get $j) (i32.const 1)))
(br $cmp)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $at)))
(return {ECHO}))
(i64.or (i64.shl (i64.const {out}) (i64.const 32)) (i64.const {OUTPUT_AT}))"#,
out = output.len(),
);
let extra = format!(r#"(data (i32.const {NEEDLE_AT}) "{}") (data (i32.const {OUTPUT_AT}) "{}")"#, string(needle), string(output));
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
}
/// The manifest of a test plug-in `id` of `kind` with `params` (a JSON object of specs).
pub fn manifest(id: &str, kind: &str, params: &str) -> String {
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"{kind}","params":{params}}}"#)
}
/// The manifest of a test exporter `id` writing `extensions` (a JSON list) with `params`.
pub fn exporter(id: &str, extensions: &str, params: &str) -> String {
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"exporter","extensions":{extensions},"params":{params}}}"#)
}
/// What [`echo_exporter`] writes as its file.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Echo {
/// The input: the document, as the host hands it over.
Input,
/// The parameters JSON, `_context` included.
Params,
}
/// Where [`echo_exporter`] keeps the base64 alphabet, then its output's opening and closing text.
const ALPHABET_AT: usize = 0x8000;
/// An exporter (manifest `manifest`) whose file is exactly what it is given (`echo`), so a test
/// reads back what the host hands a plug-in: it returns `{"dataBase64": "…"}`, encoding the bytes
/// in base64 itself, three bytes to four characters, the last group padded with `=`.
pub fn echo_exporter(manifest: &str, echo: Echo) -> String {
let (src, len) = match echo {
Echo::Input => ("$in", "$il"),
Echo::Params => ("$pp", "$pl"),
};
let (open, close) = (r#"{"dataBase64":""#, r#""}"#);
let (open_at, close_at) = (ALPHABET_AT + 64, ALPHABET_AT + 96);
// One character of the alphabet: the six bits of `$b` from `shift` up.
let char_at = |shift: u32| {
format!("(i32.load8_u (i32.add (i32.const {ALPHABET_AT}) (i32.and (i32.shr_u (local.get $b) (i32.const {shift})) (i32.const 63))))")
};
let byte = |k: u32| format!("(i32.load8_u (i32.add (local.get {src}) (i32.add (local.get $i) (i32.const {k}))))");
let run = format!(
r#"(local $o i32) (local $n i32) (local $w i32) (local $i i32) (local $b i32) (local $rest i32)
(local.set $n (i32.add (i32.const {frame}) (i32.mul (i32.const 4) (i32.div_u (i32.add (local.get {len}) (i32.const 2)) (i32.const 3)))))
(local.set $o (memory.grow (i32.add (i32.shr_u (local.get $n) (i32.const 16)) (i32.const 1))))
(if (i32.eq (local.get $o) (i32.const -1)) (then (return (i64.const -1))))
(local.set $o (i32.mul (local.get $o) (i32.const 65536)))
(memory.copy (local.get $o) (i32.const {open_at}) (i32.const {open_len}))
(local.set $w (i32.add (local.get $o) (i32.const {open_len})))
(block $done (loop $triple
(br_if $done (i32.gt_u (i32.add (local.get $i) (i32.const 3)) (local.get {len})))
(local.set $b (i32.or (i32.or (i32.shl {b0} (i32.const 16)) (i32.shl {b1} (i32.const 8))) {b2}))
(i32.store8 (local.get $w) {c18}) (i32.store8 offset=1 (local.get $w) {c12})
(i32.store8 offset=2 (local.get $w) {c6}) (i32.store8 offset=3 (local.get $w) {c0})
(local.set $w (i32.add (local.get $w) (i32.const 4)))
(local.set $i (i32.add (local.get $i) (i32.const 3)))
(br $triple)))
(local.set $rest (i32.sub (local.get {len}) (local.get $i)))
(if (i32.ne (local.get $rest) (i32.const 0)) (then
(local.set $b (i32.shl {b0} (i32.const 16)))
(if (i32.eq (local.get $rest) (i32.const 2)) (then (local.set $b (i32.or (local.get $b) (i32.shl {b1} (i32.const 8))))))
(i32.store8 (local.get $w) {c18}) (i32.store8 offset=1 (local.get $w) {c12})
(i32.store8 offset=2 (local.get $w) (if (result i32) (i32.eq (local.get $rest) (i32.const 2)) (then {c6}) (else (i32.const 61))))
(i32.store8 offset=3 (local.get $w) (i32.const 61))
(local.set $w (i32.add (local.get $w) (i32.const 4)))))
(memory.copy (local.get $w) (i32.const {close_at}) (i32.const {close_len}))
(i64.or (i64.shl (i64.extend_i32_u (local.get $n)) (i64.const 32)) (i64.extend_i32_u (local.get $o)))"#,
frame = open.len() + close.len(),
open_len = open.len(),
close_len = close.len(),
b0 = byte(0),
b1 = byte(1),
b2 = byte(2),
c18 = char_at(18),
c12 = char_at(12),
c6 = char_at(6),
c0 = char_at(0),
);
let extra = format!(
r#"(data (i32.const {ALPHABET_AT}) "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")
(data (i32.const {open_at}) "{}")
(data (i32.const {close_at}) "{}")"#,
string(open),
string(close)
);
module(manifest, &run, &extra, 1)
}
+61
View File
@@ -164,6 +164,67 @@ fn wall_clock_budget_stops_runaway_plugins() {
assert!(t < Duration::from_secs(3), "{t:?}");
}
#[test]
fn cancelling_stops_a_run_between_two_slices_of_its_budget() {
let spin = ok_module("(loop $l (br $l)) (i64.const 0)");
let p = Plugin::load(&spin, Limits { fuel_base: u64::MAX / 4, ..Limits::default() }).unwrap();
let token = vectorcraft_doc::cancel::CancelToken::new();
token.cancel();
let t = Instant::now();
let r = vectorcraft_doc::cancel::scope(&token, None, || p.run(INPUT.as_bytes(), b"{}"));
assert_eq!(r, Err(Error::Cancelled));
assert!(t.elapsed() < Duration::from_secs(3), "{:?}", t.elapsed());
// Outside a cancelled scope the same module still runs to its budget.
let (e, _) = run_err(&spin, Limits { fuel_base: 1_000_000, ..Limits::default() });
assert_eq!(e, Error::Limit("instruction budget".into()));
}
#[test]
fn an_exporter_returns_its_file_with_an_export_s_budget() {
use vectorcraft_plugins::export::{self, Target};
let target = Target { extension: "tst", name: "Poster" };
let manifest = tpl::exporter("test.exporter", r#"["tst"]"#, r#"{"level":{"type":"int","min":1,"max":3,"default":2}}"#);
let p = Plugin::load(&build(&tpl::returning(&manifest, r#"{"dataBase64":"SGk=","warnings":["note"]}"#)), Limits::default()).unwrap();
assert_eq!((p.manifest().kind, p.manifest().extensions.clone()), (Kind::Exporter, vec!["tst".to_string()]));
let out = export::run(&p, br#"{"format":"vectorcraft"}"#, &json!({"level": 9}), target).unwrap();
assert_eq!((out.data_base64.as_str(), out.warnings), ("SGk=", vec!["note".to_string()]));
let limits = export::limits(&p);
assert_eq!((limits.fuel_base, limits.wall_time_ms), (export::FUEL, export::WALL_TIME_MS));
// Its parameters are checked, and a filter is not an exporter.
assert!(matches!(export::run(&p, b"{}", &json!({"level": "high"}), target), Err(Error::Params(_))));
let filter = Plugin::load(&ok_module("(i64.const 0)"), Limits::default()).unwrap();
assert!(matches!(export::run(&filter, b"{}", &Value::Null, target), Err(Error::Params(_))));
}
/// The echo exporter the engine and UI tests read back what they hand a plug-in with: its file is
/// its input, or its parameters, byte for byte, whatever their length modulo three.
#[test]
fn the_echo_exporter_writes_what_it_is_given() {
use vectorcraft_plugins::export::{self, Target};
let manifest = tpl::exporter("test.echo", r#"["txt"]"#, "{}");
let input = Plugin::load(&build(&tpl::echo_exporter(&manifest, tpl::Echo::Input)), Limits::default()).unwrap();
let params = Plugin::load(&build(&tpl::echo_exporter(&manifest, tpl::Echo::Params)), Limits::default()).unwrap();
let target = Target { extension: "txt", name: "a" };
let decoded = |out: export::Exported| String::from_utf8(base64_decode(&out.data_base64)).unwrap();
for doc in ["{}", r#"{"a":1}"#, r#"{"ab":12}"#, r#"{"format":"vectorcraft","é":"\u0000"}"#] {
assert_eq!(decoded(export::run(&input, doc.as_bytes(), &Value::Null, target).unwrap()), doc);
}
let sent: Value = serde_json::from_str(&decoded(export::run(&params, b"{}", &Value::Null, target).unwrap())).unwrap();
assert_eq!(sent, json!({"_context": {"mode": "export", "extension": "txt", "name": "a"}}));
}
/// Standard base64 (RFC 4648, padded), decoded here so the test needs no other crate.
fn base64_decode(s: &str) -> Vec<u8> {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let sextets: Vec<u32> = s.bytes().filter(|b| *b != b'=').map(|b| ALPHABET.iter().position(|a| *a == b).unwrap() as u32).collect();
let mut out = Vec::new();
for group in sextets.chunks(4) {
let bits = group.iter().enumerate().fold(0u32, |acc, (i, v)| acc | v << (18 - 6 * i));
out.extend(bits.to_be_bytes()[1..].iter().take(group.len() - 1));
}
out
}
#[test]
fn memory_cap_limits_input_and_growth() {
let echo = build(&tpl::module(MANIFEST, tpl::ECHO, "", 1));
+17 -1
View File
@@ -85,6 +85,22 @@ pub(crate) fn font_stretch(st: &CharStyle) -> Option<f64> {
st.font_axes.get("wdth").copied().filter(|w| w.is_finite() && *w > 0.0)
}
/// The generic family to list after `family`, with its leading comma, so a viewer without the font draws
/// the kind of face it was (the font's class, as the font menu's filters know it) rather than its default
/// serif. Nothing when `family` is a generic family itself.
fn generic_fallback(family: &str) -> &'static str {
const GENERIC: [&str; 5] = ["serif", "sans-serif", "monospace", "cursive", "fantasy"];
if GENERIC.iter().any(|g| family.trim().eq_ignore_ascii_case(g)) {
return "";
}
match vectorcraft_text::FontDb::global().family_traits(family).class {
vectorcraft_text::FontClass::Serif => ", serif",
vectorcraft_text::FontClass::Monospaced => ", monospace",
vectorcraft_text::FontClass::Script => ", cursive",
_ => ", sans-serif",
}
}
/// The font of character style `st`: family, size (the vertical scale; writers stretch the
/// horizontal one), weight and style, and its Variable Font settings, lengths written by `len`.
/// The weight is the style's number (600 for Semibold, 300 for Light) or the `wght` setting,
@@ -98,7 +114,7 @@ pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
} else {
st.font_family.clone()
};
p.push(("font-family", fam));
p.push(("font-family", format!("{fam}{}", generic_fallback(&st.font_family))));
p.push(("font-size", len(st.size * st.v_scale / 100.0)));
let (weight, italic) = font_descriptor(st);
match weight {
+2 -2
View File
@@ -135,12 +135,12 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
}
if !doc.title.is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<title>{}</title>{nl}", xml_escape(&doc.title)));
out.push_str(&format!("<title>{}</title>{nl}", xml_text(&doc.title)));
}
// File Info's description.
if !doc.metadata.description.trim().is_empty() {
out.push_str(&w.indent(1));
out.push_str(&format!("<desc>{}</desc>{nl}", xml_escape(&doc.metadata.description)));
out.push_str(&format!("<desc>{}</desc>{nl}", xml_text(&doc.metadata.description)));
}
// Window › SVG Interactivity › Link JavaScript Files.
for file in &doc.svg_scripts {
+19 -2
View File
@@ -163,7 +163,7 @@ fn type_gives_font_properties() {
let id = add(&mut d, n);
let r = one(&d, id, &CssOptions::default());
assert_eq!(r.selector, ".type");
assert_eq!(prop(&r, "font-family"), Some("'Source Sans 3'"));
assert_eq!(prop(&r, "font-family"), Some("'Source Sans 3', sans-serif"));
assert_eq!(prop(&r, "font-size"), Some("18px"));
assert_eq!(prop(&r, "font-weight"), Some("bold"));
assert_eq!(prop(&r, "font-style"), Some("italic"));
@@ -281,9 +281,26 @@ fn groups_stand_for_their_objects_and_hidden_ones_are_left_out() {
assert_eq!(sheet.text(), sheet.rules[0].text());
}
#[test]
fn font_family_ends_with_a_generic_family_for_viewers_without_the_font() {
let family = |name: &str| {
let mut d = doc();
let st = CharStyle { font_family: name.into(), size: 18.0, ..CharStyle::default() };
let n = text(&mut d, "Hello", st);
let id = add(&mut d, n);
prop(&one(&d, id, &CssOptions::default()), "font-family").map(str::to_string)
};
// Source Sans 3 is a sans face; an unknown family gets the same default; a generic family stands alone.
assert_eq!(family("Source Sans 3").as_deref(), Some("'Source Sans 3', sans-serif"));
assert_eq!(family("No Such Font").as_deref(), Some("'No Such Font', sans-serif"));
assert_eq!(family("serif").as_deref(), Some("serif"));
}
#[test]
fn font_family_with_quote_or_backslash_is_escaped() {
for (family, want) in [("Bob's Font", r"'Bob\'s Font'"), (r"Back\slash Font", r"'Back\\slash Font'"), ("Bob's", r"'Bob\'s'")] {
for (family, want) in
[("Bob's Font", r"'Bob\'s Font', sans-serif"), (r"Back\slash Font", r"'Back\\slash Font', sans-serif"), ("Bob's", r"'Bob\'s', sans-serif")]
{
let mut d = doc();
let st = CharStyle { font_family: family.into(), size: 18.0, ..CharStyle::default() };
let n = text(&mut d, "Hello", st);
+1 -1
View File
@@ -300,7 +300,7 @@ fn lines_without_moved_characters_are_written_as_before() {
}
let open = |x: &str, anchor: &str| {
format!(
r##"<text transform="matrix(1 0 0 1 20 20)" x="{x}" y="12" xml:space="preserve" font-family="&apos;Source Sans 3&apos;" font-size="12" fill="#000000"{anchor}>"##
r##"<text transform="matrix(1 0 0 1 20 20)" x="{x}" y="12" xml:space="preserve" font-family="&apos;Source Sans 3&apos;, sans-serif" font-size="12" fill="#000000"{anchor}>"##
)
};
let words =
+26
View File
@@ -0,0 +1,26 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_doc::Document;
use vectorcraft_svg::{ExportOptions, export, import};
#[test]
fn title_and_description_control_chars_are_filtered() {
let mut d = Document::new(200.0, 200.0);
d.title = "Poster\u{b}v2".into();
d.metadata.description = "a\u{1}b".into();
let svg = export(&d, &ExportOptions::default());
assert!(import(&svg).is_ok(), "export wrote invalid XML: {svg}");
assert!(svg.contains("<title>Poster v2</title>"));
assert!(svg.contains("<desc>ab</desc>"));
}
#[test]
fn title_and_description_markup_is_escaped() {
let mut d = Document::new(200.0, 200.0);
d.title = "A & <B>".into();
d.metadata.description = "x & y".into();
let svg = export(&d, &ExportOptions::default());
assert!(import(&svg).is_ok(), "export wrote invalid XML: {svg}");
assert!(svg.contains("<title>A &amp; &lt;B&gt;</title>"));
assert!(svg.contains("<desc>x &amp; y</desc>"));
}
+67 -18
View File
@@ -62,6 +62,8 @@ enum State {
start: Point,
grab: Option<Point>,
began: bool,
/// Group Selection: Shift-click on a selected object toggles it off on release.
deselect: Option<NodeId>,
},
/// Dragging a curved segment: it follows the pointer (`path.reshapeSegment`).
Segment {
@@ -452,14 +454,25 @@ impl Tool for DirectSelectionTool {
} else {
h.leaf
};
self.state = State::MoveObject { start: p, grab: None, began: false };
if cx.selection.contains(target) {
vec![]
} else if ev.mods.shift || cx.selection.contains(h.leaf) {
vec![Action::Exec("select.add".into(), json!({"ids": [target.0]}))]
} else {
vec![Action::Exec("select.set".into(), json!({"ids": [target.0]}))]
let mut deselect = None;
let mut out = vec![];
if ev.mods.shift {
// Shift on a selected member deselects what was hit (a compound path's part),
// not the next ancestor a plain click would add (#2441).
if cx.selection.contains(h.leaf) {
deselect = Some(h.leaf);
} else {
out.push(Action::Exec("select.toggle".into(), json!({"id": target.0})));
}
} else if !cx.selection.contains(target) {
if cx.selection.contains(h.leaf) {
out.push(Action::Exec("select.add".into(), json!({"ids": [target.0]})));
} else {
out.push(Action::Exec("select.set".into(), json!({"ids": [target.0]})));
}
}
self.state = State::MoveObject { start: p, grab: None, began: false, deselect };
out
}
(PointerKind::DoubleClick, _) if self.group => release_guide_at(cx, p, ev.mods).into_iter().collect(),
(PointerKind::DoubleClick, _) if !self.group => {
@@ -503,7 +516,7 @@ impl Tool for DirectSelectionTool {
self.snapped = false;
if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {
self.state = State::Mesh;
return self.mesh.press(g);
return self.mesh.press(cx, g);
}
self.mesh.unfocus();
if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {
@@ -522,7 +535,7 @@ impl Tool for DirectSelectionTool {
}
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
if ev.mods.alt {
self.state = State::MoveObject { start: p, grab: Some(grab), began: false };
self.state = State::MoveObject { start: p, grab: Some(grab), began: false, deselect: None };
let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none();
return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
@@ -533,7 +546,7 @@ impl Tool for DirectSelectionTool {
// Paths all selected whole move as objects, held by this anchor, so Alt can turn
// the copy on and off during the drag as with the Selection tool.
self.state = if already && cx.selection.anchors.is_empty() {
State::MoveObject { start: p, grab: Some(grab), began: false }
State::MoveObject { start: p, grab: Some(grab), began: false, deselect: None }
} else {
State::MoveAnchors { start: p, grab, began: false }
};
@@ -586,7 +599,7 @@ impl Tool for DirectSelectionTool {
}
}
// Clicking the fill selects the whole leaf path (all anchors).
self.state = State::MoveObject { start: p, grab: None, began: false };
self.state = State::MoveObject { start: p, grab: None, began: false, deselect: None };
if ev.mods.shift {
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
}
@@ -638,14 +651,14 @@ impl Tool for DirectSelectionTool {
));
out
}
(PointerKind::Drag, State::MoveObject { start, grab, began }) => {
(PointerKind::Drag, State::MoveObject { start, grab, began, .. }) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() }));
self.state = State::MoveObject { start, grab, began: true };
self.state = State::MoveObject { start, grab, began: true, deselect: None };
if grab.is_some() {
// Alt can go down or up at any time: gather both while nothing has moved.
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
@@ -763,16 +776,23 @@ impl Tool for DirectSelectionTool {
| State::SpineSegment { began, .. }
| State::TypeArea { began, .. },
) => {
let deselect = match self.state {
State::MoveObject { deselect, .. } => deselect,
_ => None,
};
self.state = State::Idle;
self.guides.clear();
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
match (began, self.click.take(), self.click_segment.take()) {
(true, _, _) => vec![Action::Commit],
(false, Some((id, si, ai)), _) => {
match (began, deselect, self.click.take(), self.click_segment.take()) {
(true, ..) => vec![Action::Commit],
(false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))],
(false, None, Some((id, si, ai)), _) => {
vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "set"}))]
}
(false, None, Some((id, si, seg))) => vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))],
(false, None, None) => vec![],
(false, None, None, Some((id, si, seg))) => {
vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[si, seg]]}))]
}
(false, None, None, None) => vec![],
}
}
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
@@ -1377,6 +1397,35 @@ mod tests {
assert_eq!(click(&s, 25.0, 100.0), vec![Action::Exec("select.add".into(), json!({"ids": [compound.0]}))]);
}
/// Group Selection Shift-click on a selected compound-path member deselects it on release.
#[test]
fn group_selection_shift_click_deselects_a_compound_path_member() {
let mut d = vectorcraft_doc::Document::new(500.0, 500.0);
let l = d.layers[0].id;
let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let rect = |id, a: f64, b: f64| {
std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default()))
};
let mut c = vectorcraft_doc::Node::new(
compound,
vectorcraft_doc::NodeKind::Compound {
children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)],
rule: vectorcraft_geom::FillRule::EvenOdd,
},
);
c.appearance = vectorcraft_doc::Appearance::default_art().into();
d.insert(Some(l), 0, c).unwrap();
let p = paint();
let mut s = Selection::default();
s.set([outer]);
let shift = Mods { shift: true, ..Mods::default() };
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(true);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0).with_mods(shift));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 25.0, 100.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"id": outer.0}))]);
}
/// A path inside an inner group, a sibling path outside it, and the groups' ids.
/// The inside path is the rectangle at (100, 100); the outside one at (300, 300).
fn nested_group_doc() -> (vectorcraft_doc::Document, NodeId, NodeId, NodeId, NodeId) {
+1 -1
View File
@@ -190,7 +190,7 @@ impl Tool for MeshTool {
if cx.selection.objects != [g.id] && cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) {
out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]})));
}
out.extend(self.edit.press(g));
out.extend(self.edit.press(cx, g));
return out;
}
self.edit.unfocus();
+46 -6
View File
@@ -8,7 +8,7 @@ use std::borrow::Cow;
use serde_json::{Value, json};
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
use vectorcraft_geom::Point;
use vectorcraft_geom::{Point, Vec2};
use crate::{Action, Overlay, ToolContext};
@@ -35,12 +35,21 @@ pub struct MeshGrab {
pub handle: Option<usize>,
}
/// An in-progress mesh point or handle drag.
struct ActiveDrag {
grab: MeshGrab,
/// Where the grabbed point or handle was when the drag began.
origin: Point,
/// The current drag position (preview target); set on the first `drag_to`.
at: Option<Point>,
}
/// Point and handle drags on meshes.
#[derive(Default)]
pub struct MeshEdit {
/// The point last clicked: its handles show and can be dragged.
focus: Option<(NodeId, usize)>,
drag: Option<MeshGrab>,
drag: Option<ActiveDrag>,
}
impl MeshEdit {
@@ -63,15 +72,23 @@ impl MeshEdit {
}
/// Start dragging `grab` (its point takes the focus).
pub fn press(&mut self, grab: MeshGrab) -> Vec<Action> {
pub fn press(&mut self, cx: &ToolContext, grab: MeshGrab) -> Vec<Action> {
self.focus = Some((grab.id, grab.index));
self.drag = Some(grab);
let mut origin = Point::ZERO;
if let Some(grid) = cx.doc.node(grab.id).and_then(grid_of)
&& let Some(q) = grid.points.get(grab.index)
{
origin = if let Some(h) = grab.handle { q.p + q.handles[h] } else { q.p };
}
self.drag = Some(ActiveDrag { grab, origin, at: None });
vec![Action::Begin("Move Mesh Point".into())]
}
/// The preview of a drag to `p` (`None` when nothing is being dragged).
pub fn drag_to(&self, p: Point) -> Option<Vec<Action>> {
let g = self.drag?;
pub fn drag_to(&mut self, p: Point) -> Option<Vec<Action>> {
let d = self.drag.as_mut()?;
d.at = Some(p);
let g = d.grab;
let mut params = json!({"id": g.id.0, "index": g.index, "x": p.x, "y": p.y});
if let Some(h) = g.handle {
params["handle"] = json!(h);
@@ -109,6 +126,13 @@ impl MeshEdit {
out.push(Overlay::Line { a: q.p, b: q.p + *h, color: FEEDBACK, dashed: false });
out.push(Overlay::Handle { p: q.p + *h, color: FEEDBACK });
}
if cx.measurement_labels
&& let Some(d) = &self.drag
&& let Some(at) = d.at
{
let delta = at - d.origin;
out.push(Overlay::Measure { p: at + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: cx.offset_label(delta.x, delta.y) });
}
out
}
}
@@ -188,4 +212,20 @@ mod tests {
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)), vec![Action::Commit]);
}
#[test]
fn mesh_drag_honors_measurement_labels() {
let (d, _e) = doc_with_envelope();
let (s, p) = (Selection::default(), paint());
let base = cx(&d, &s, &p);
let on = ToolContext { measurement_labels: true, ..base };
let off = ToolContext { measurement_labels: false, ..base };
let mut t = crate::meshblend::create("mesh").unwrap();
t.pointer(&on, &PointerEvent::new(PointerKind::Down, 200.0, 100.0));
assert!(!t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { .. })));
t.pointer(&on, &PointerEvent::new(PointerKind::Drag, 210.0, 90.0));
let want = on.offset_label(10.0, -10.0);
assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want)));
assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. })));
}
}
+49 -1
View File
@@ -1,7 +1,9 @@
//! Ruler guides on the canvas. Dragged out of a ruler with any tool ([`NewGuide`]), a guide is
//! made where it is released over the canvas: with the Artboard tool, an artboard guide of the
//! active artboard. For the selection tools ([`GuideEdit`]), a press on a guide selects it (Shift
//! adds it to the selected guides or, released without a drag, takes it out); dragging moves the
//! adds it to the selected guides or, released without a drag, takes it out; a click on a guide
//! selected with objects makes it the key object Align lines them up on, `select.key`, and a
//! click on the key guide again cancels it); dragging moves the
//! selected guides (Alt copies them), and a marquee selects the guides it crosses along with the
//! art ([`marquee_select`]). A dragged guide snaps ([`GuideSnap`]): with Shift to the
//! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards'
@@ -92,6 +94,9 @@ struct Drag {
began: bool,
/// Shift-pressed on a selected guide: released without a drag, it leaves the selection.
deselect: bool,
/// Pressed (no Shift) on a guide selected with objects: released without a drag, it becomes
/// the key object Align lines them up on, or stops being it when it was.
key: bool,
}
/// Snapping for a dragged ruler guide, its targets gathered when the drag begins.
@@ -246,6 +251,7 @@ impl GuideEdit {
pointer: ev.pos,
began: false,
deselect: shift && selected,
key: !shift && selected && !cx.selection.is_empty(),
});
self.snap = GuideSnap::default();
Some(match (selected, shift) {
@@ -278,6 +284,11 @@ impl GuideEdit {
vec![Action::Commit]
} else if d.deselect {
vec![Action::Exec("guide.select".into(), json!({ "indexes": [d.index], "toggle": true }))]
} else if d.key && cx.selection.key_guide == Some(d.index) {
// A click on the key guide again: no key.
vec![Action::Exec("select.key".into(), json!({}))]
} else if d.key {
vec![Action::Exec("select.key".into(), json!({ "guide": d.index }))]
} else {
vec![]
}
@@ -620,6 +631,43 @@ mod tests {
assert_eq!(a[1], Action::Preview("guide.move".into(), json!({"dx": 0.0, "dy": 20.0, "copy": true})));
}
/// #2143: a click on a guide selected with objects makes it the key; on the key guide again,
/// no key. Alone (no objects), with Shift, or dragged, it doesn't.
#[test]
fn a_click_on_a_guide_selected_with_objects_makes_it_the_key() {
// The same document as doc_with_rect's, with the guides.
let (d, rect) = (guides_doc(), doc_with_rect().1);
let p = paint();
let click = |s: &Selection, m: Mods| {
let c = cx(&d, s, &p);
let mut g = GuideEdit::default();
let mut out = g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, m)).unwrap();
out.extend(g.pointer(&c, &ev(PointerKind::Up, 302.0, 50.0, m)).unwrap());
out
};
let mut s = Selection::default();
s.set([rect]);
s.toggle_guide(0);
let make = Action::Exec("select.key".into(), json!({"guide": 0}));
assert_eq!(click(&s, Mods::default()), vec![make]);
s.key_guide = Some(0);
assert_eq!(click(&s, Mods::default()), vec![Action::Exec("select.key".into(), json!({}))], "the key guide again");
let shift = Mods { shift: true, ..Default::default() };
assert_eq!(click(&s, shift), vec![Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true}))]);
s.set_guides([0]);
assert_eq!(click(&s, Mods::default()), vec![], "a guide alone has nothing to align");
// Not selected yet: the first click selects it.
s.set([rect]);
assert_eq!(click(&s, Mods::default()), vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]);
// A drag moves it.
s.toggle_guide(0);
let c = cx(&d, &s, &p);
let mut g = GuideEdit::default();
g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, Mods::default()));
g.pointer(&c, &ev(PointerKind::Drag, 340.0, 50.0, Mods::default()));
assert_eq!(g.pointer(&c, &ev(PointerKind::Up, 340.0, 50.0, Mods::default())), Some(vec![Action::Commit]));
}
#[test]
fn dropped_on_its_ruler_the_guide_is_deleted() {
let d = guides_doc();
+10 -6
View File
@@ -48,9 +48,9 @@ enum State {
/// Shift-pressed on this selected object: released without a drag, it leaves the
/// selection.
deselect: Option<NodeId>,
/// Pressed (no modifier) on this object of a selection of several: released without a
/// drag, it becomes the key object that Align aligns to if `can_be_key` allows it (the key
/// again: no key).
/// Pressed (no modifier) on this object of a selection of several, or of one with ruler
/// guides: released without a drag, it becomes the key object that Align aligns to if
/// `can_be_key` allows it (the key again: no key).
key: Option<NodeId>,
},
Scaling {
@@ -582,7 +582,7 @@ impl Tool for SelectionTool {
}
} else if !cx.selection.contains(top) {
out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] })));
} else if cx.selection.objects.len() > 1 && !m.cmd && !m.alt {
} else if (cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty()) && !m.cmd && !m.alt {
key = Some(top);
}
self.state = State::Moving { start: grab, began: false, deselect, key };
@@ -1442,7 +1442,8 @@ mod tests {
}
/// #541: a click on one object of a selection of several makes it the key object; a click on
/// the key again lets it go. A drag moves them all, a lone object has no key.
/// the key again lets it go. A drag moves them all, a lone object has no key unless ruler
/// guides are selected with it.
#[test]
fn a_click_on_a_selected_object_makes_it_the_key() {
let (d, id) = doc_with_area_type();
@@ -1466,10 +1467,13 @@ mod tests {
t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0));
t.pointer(&c, &ev(PointerKind::Drag, 170.0, 150.0));
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 170.0, 150.0)), vec![Action::Commit]);
// One object selected: a click on it does nothing.
// One object selected: a click on it does nothing, unless ruler guides are selected
// with it for it to be the key of (#2143).
let mut one = Selection::default();
one.add(rect);
assert!(click(&one, 150.0, 150.0).is_empty());
one.guides = vec![0];
assert_eq!(click(&one, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]);
}
/// In isolation mode a click makes only a selected top-level object the key, as `select.key`
+8 -1
View File
@@ -502,6 +502,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// Selected guides in the selection colour.
let picked = LineLook { color: t.selection, ..look };
let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides);
let key = app.session.active().and_then(|st| st.selection.key_guide);
let view = view_bounds(&xf);
// Guides on a hidden layer hide with it.
for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) {
@@ -517,6 +518,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
};
if selected.contains(&i) { picked } else { look }.guide(&painter, a, b);
// The key guide (Align to Key Object, #2143): thicker, as a key object's outline is.
if key == Some(i) {
painter.line_segment([a, b], Stroke::new(KEY_OUTLINE, picked.color));
}
}
}
@@ -2040,6 +2045,8 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf)
// Art dragged back onto the canvas: its move was already dropped. (A symbol was placed
// above.)
widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) | widgets::PanelDrag::LibraryGraphic { .. } => return,
// A Libraries panel style or group only moves within that panel.
widgets::PanelDrag::LibraryItem { .. } | widgets::PanelDrag::LibraryGroup { .. } => return,
};
if let Err(e) = app.run(cmd, params) {
app.status(e);
@@ -6958,7 +6965,7 @@ mod tests {
let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
let red = Paint::solid(Color::from_hex("#ed1c24").unwrap());
let rows = SwatchRows { grabbed: "Red".into(), names: vec!["Red".into()], groups: false };
let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows) };
let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows), library: None };
// Off the art nothing happens.
drop(&mut app, swatch.clone(), Point::new(300.0, 250.0));
assert_eq!(fill(&app), Paint::solid(Color::WHITE));
+36 -2
View File
@@ -509,7 +509,12 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
crate::panels::artboards::select(app, selected);
}
}
// Isolation mode breadcrumb bar.
}
/// The isolation mode breadcrumb bar. It is drawn over the top of `area` (the canvas), after the
/// canvas, so entering isolation mode does not change the canvas's size (#3126).
pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) {
let t = Tokens::get(ui.ctx());
if let Some(st) = app.session.active()
&& let Some(iso) = st.isolation
{
@@ -520,7 +525,8 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
if let Some(pn) = &pattern_edit {
crumbs.push(pn.clone());
}
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
let bar = egui::Rect::from_min_size(area.min, vec2(area.width(), 24.0));
ui.interact(bar, ui.id().with("iso-bar"), Sense::click_and_drag());
ui.painter().rect_filled(bar, 0.0, t.panel);
let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0));
let bresp = ui.interact(back, ui.id().with("iso-back"), Sense::click());
@@ -1876,6 +1882,34 @@ mod tests {
ctx.viewport(|vp| vp.prev_pass.widgets.get(egui::Id::new(super::ISOLATION_BUTTON)).map(|w| w.rect))
}
/// Entering isolation mode leaves the canvas where it was: the breadcrumb bar is drawn over the
/// canvas and takes no layout height from it (#3126).
#[test]
fn the_isolation_bar_does_not_take_layout_height_from_the_canvas() {
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
let top = |app: &mut crate::VectorcraftApp| {
let mut below = 0.0;
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
super::doc_tabs(app, ui);
below = ui.available_rect_before_wrap().top();
let area = ui.available_rect_before_wrap();
super::isolation_bar(app, ui, area);
assert_eq!(ui.available_rect_before_wrap().top(), below, "the bar takes no layout space");
});
out.textures_delta.clear();
below
};
let before = top(&mut app);
app.run("object.isolate", json!({"id": id})).unwrap();
assert!(app.session.active().unwrap().isolation.is_some());
assert_eq!(top(&mut app), before, "isolation mode leaves the canvas where it was");
}
/// The Control bar's Isolate Selected Object isolates a path inside a group that Direct
/// Selection selected, and Exit Isolation Mode leaves with the path selected. Type, symbol
/// instances, blends, two objects and an envelope's contents get no isolate button, and pattern
+30 -4
View File
@@ -1,6 +1,6 @@
//! Artboard Options (double-click with the Artboard tool, double-click the tool, or the Artboards
//! panel's options icon or menu): the artboard's fields, sizes parsed with units, and its position
//! from a reference point.
//! from a reference point. A new size also goes to the other selected artboards.
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
@@ -31,10 +31,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::grid_then(ui, d, unit, |ui, d| {
let (w, h) = (d.f64("width", 0.0), d.f64("height", 0.0));
let n = artboard_rect(was, refi, None, None, Some(w), Some(h), false);
// Only the side that changed moves, so an untouched coordinate stays exactly as it was.
for (key, changed, at) in [("x", w != w0, n.x0), ("y", h != h0, n.y0)] {
// Only the side that changed moves, so an untouched coordinate stays exactly as it was. The
// reference point's column or row is kept for the other selected artboards (`confirm`).
for (key, changed, at, part) in [("x", w != w0, n.x0, refi % 3), ("y", h != h0, n.y0, refi / 3)] {
if changed {
d.fields.insert(key.into(), json!(at));
d.fields.insert(format!("__ref{key}"), json!(part));
}
}
position_rows(ui, d, refi, unit, origin);
@@ -92,6 +94,10 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
Ok(json!({ "dialog": KIND }))
}
/// OK sets the artboard's fields. A Width or Height that differs from the artboard's also goes to
/// the other selected artboards (when this one is among them), each resized about the reference
/// point the form's last Width or Height change used (fields `__refx` and `__refy`, its column and
/// row; the top-left corner when the form set none), all in one undo step.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if d.bool("new") {
return confirm_new(app, d);
@@ -102,7 +108,27 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
p[k] = json!(d.f64(k, 0.0));
}
}
p["moveArt"] = json!(crate::panels::artboards::move_art(app));
if let Some(o) = p.as_object_mut() {
o.retain(|k, _| !k.starts_with("__"));
}
let index = p["index"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(0);
let was = app.session.active().and_then(|st| st.doc.artboards.get(index)).map(|a| a.rect);
let changed = |k: &str, old: Option<f64>| p[k].as_f64().filter(|v| old.is_some_and(|old| (v - old).abs() > 1e-6));
let (w, h) = (changed("width", was.map(|r| r.width())), changed("height", was.map(|r| r.height())));
let part = |k: &str| d.fields.get(k).and_then(Value::as_u64).map_or(0, |v| v.min(2) as usize);
let refi = part("__refy") * 3 + part("__refx");
let mut entries = vec![];
if w.is_some() || h.is_some() {
for i in crate::panels::artboards::resized_with(app, index).into_iter().filter(|i| *i != index) {
if let Some(r) = app.session.active().and_then(|st| st.doc.artboards.get(i)).map(|a| a.rect) {
let n = artboard_rect(r, refi, None, None, w, h, false);
entries.push(json!({"index": i, "x": n.x0, "y": n.y0, "width": n.width(), "height": n.height()}));
}
}
}
entries.insert(0, p);
let common = json!({"moveArt": crate::panels::artboards::move_art(app)});
let p = crate::panels::artboards::set_props_params(common, entries).ok_or("no artboard")?;
run_and_close(app, "artboard.setProps", p)
}
@@ -8,7 +8,8 @@
//! Scatter: per value (`size`, `spacing`, `scatter` %, `rotation` °) `…Min`, `…Max` and `…Mode`
//! (`fixed` | `random` | `pressure`), `rotationRelativeTo` (`page` | `path`).
//! Art: `width` (%), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and
//! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`.
//! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`,
//! `overlap` (bend on each run between sharp corners).
//! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`,
//! `flipAcross`, `__tiles` (slot → preview key, for the tiles the brush has).
@@ -96,6 +97,7 @@ pub(super) fn fields(b: &Brush, def: Value, f: &mut Map<String, Value>) {
f.insert("direction".into(), json!(id_of(&a.direction)));
f.insert("flipAlong".into(), json!(a.flip_along));
f.insert("flipAcross".into(), json!(a.flip_across));
f.insert("overlap".into(), json!(a.overlap));
}
BrushKind::Pattern(p) => {
f.insert("scale".into(), json!(p.scale));
@@ -168,6 +170,7 @@ pub(super) fn params(d: &Dialog) -> Value {
"direction": id_of(&parse_field::<Direction>(d, "direction")),
"flip_along": d.bool("flipAlong"),
"flip_across": d.bool("flipAcross"),
"overlap": d.bool("overlap"),
})
}
_ => json!({
@@ -284,6 +287,8 @@ fn art_body(ui: &mut egui::Ui, d: &mut Dialog) {
}
});
flips(ui, d);
ui.add_space(4.0);
super::form::check(ui, d, "overlap", tl!("Overlap"));
}
/// A toggle button showing an arrow pointing the way `dir` runs. Whether it was clicked.
+5 -3
View File
@@ -154,7 +154,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
// Plug-in effects get fields from their parameter schema; colour adjustments their sliders.
let changed = match vectorcraft_plugins::effect::installed(&id) {
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
Some(plugin) => form::schema_fields(app, ui, d, plugin.manifest()),
None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d),
None if id == GLASS => glass_fields(app, ui, d),
None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()),
@@ -283,10 +283,12 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
("brushStrokes.sprayedStrokes", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS),
("distort.glass", "texture") => Some(&vectorcraft_effects::pixel::GLASS_TEXTURES),
("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES),
("sketch.graphicPen", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS),
("sketch.halftonePattern", "patternType") => Some(&vectorcraft_effects::pixel::HALFTONE_TYPES),
("sketch.plaster", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES),
("texture.texturizer", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES),
("texture.texturizer", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
("sketch.basRelief" | "texture.texturizer", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]),
("video.deinterlace", "create") => Some(&[("Duplication", "duplication"), ("Interpolation", "interpolation")]),
("stylize.innerGlow", "source") => Some(&[("Center", "center"), ("Edge", "edge")]),
@@ -334,7 +336,7 @@ pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'
/// of these lists.
pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map<String, Value>, cmyk: bool) -> Option<Vec<(String, String)>> {
if let Some(plugin) = vectorcraft_plugins::effect::installed(effect) {
return plugin.manifest().params.iter().find(|(k, _)| k == key).and_then(|(_, spec)| match spec {
return plugin.manifest().param(key).and_then(|p| match &p.spec {
vectorcraft_plugins::ParamSpec::Choice { options, .. } => Some(options.iter().map(|o| (o.clone(), o.clone())).collect()),
_ => None,
});
+36 -13
View File
@@ -2,14 +2,16 @@
//! artboard, or one page each in a PDF); without artboards the export covers the visible art. OK
//! picks the file, then the format's options dialog follows (PNG/JPEG/WebP/PNG-8/GIF Options, SVG
//! Options, Text Export Options, DXF Options, EPS Options, and Save PDF for artboards as pages),
//! else the file is written. DWG is listed, greyed out, with what to use instead.
//! else the file is written. DWG is listed, greyed out, with what to use instead. The formats of
//! installed exporter plug-ins come after the built-in ones: they write the whole document, and one
//! with parameters asks for them before writing (`plugin::open_export`).
use std::sync::LazyLock;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
use super::{DialogSpec, dxf_options, eps_options, form, png_options, save_pdf, svg_options, text_export};
use super::{DialogSpec, dxf_options, eps_options, form, plugin, png_options, save_pdf, svg_options, text_export};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
@@ -17,12 +19,21 @@ use crate::{VectorcraftApp, io, widgets};
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Export As").into(), body, confirm, ok: Some("Export…"), min_width: 380.0, ..DialogSpec::FORM };
/// The formats Export As writes (the native format and `.ai` are Save As's).
static FORMATS: LazyLock<Vec<&'static Format>> =
/// The built-in formats Export As writes (the native format and `.ai` are Save As's).
static BUILT_IN: LazyLock<Vec<&'static Format>> =
LazyLock::new(|| fileio::FORMATS.iter().filter(|f| f.write && !matches!(f.id, "vectorcraft" | "ai")).collect());
/// Formats listed after them that can't be written (`fileio::unsupported` ids), greyed out.
const UNAVAILABLE: [(&str, &str); 1] = [("dwg", "DWG (use DXF)")];
static LABELS: LazyLock<Vec<&'static str>> = LazyLock::new(|| FORMATS.iter().map(|f| f.label).chain(UNAVAILABLE.iter().map(|u| u.1)).collect());
/// The formats Export As writes: the built-in ones, then those of the installed exporter plug-ins.
pub(super) fn formats() -> Vec<&'static Format> {
BUILT_IN.iter().copied().chain(fileio::plugin_formats()).collect()
}
/// Is `f` an exporter plug-in's format?
fn from_plugin(f: &Format) -> bool {
vectorcraft_plugins::export::by_format(f.id).is_some()
}
/// What to use instead when the field names a format that can't be written (DWG).
fn unavailable(d: &Dialog) -> Option<(&'static str, &'static str)> {
@@ -38,7 +49,7 @@ pub fn open(app: &mut VectorcraftApp, format: Option<&str>, p: &Value) {
let missing = format.and_then(|f| UNAVAILABLE.iter().find(|u| u.0.eq_ignore_ascii_case(f.trim_start_matches('.'))));
let format = match missing {
Some((id, _)) => id,
None => format.and_then(fileio::format).filter(|f| FORMATS.iter().any(|g| g.id == f.id)).map_or("png", |f| f.id),
None => format.and_then(fileio::format).filter(|f| formats().iter().any(|g| g.id == f.id)).map_or("png", |f| f.id),
};
let range = p.get("range").and_then(Value::as_str);
app.ui.dialog = Some(Dialog::new(
@@ -55,7 +66,8 @@ pub fn open(app: &mut VectorcraftApp, format: Option<&str>, p: &Value) {
/// The chosen format (PNG when the field names none Export As writes).
fn chosen(d: &Dialog) -> Option<&'static Format> {
let id = d.str("format");
let find = |id: &str| FORMATS.iter().copied().find(|f| f.id == id);
let all = formats();
let find = |id: &str| all.iter().copied().find(|f| f.id == id);
find(&id).or_else(|| find("png"))
}
@@ -66,7 +78,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
egui::Grid::new("export-as").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
ui.label(egui::RichText::new(tl!("Format:")).color(t.text_dim));
let shown = missing.map_or(f.label, |(label, _)| label);
if let Some(g) = widgets::dropdown_with(ui, "export-as-format", shown, &LABELS, 160.0, |i| i < FORMATS.len()).and_then(|i| FORMATS.get(i)) {
let all = formats();
let labels: Vec<&str> = all.iter().map(|f| f.label).chain(UNAVAILABLE.iter().map(|u| u.1)).collect();
if let Some(g) = widgets::dropdown_with(ui, "export-as-format", shown, &labels, 160.0, |i| i < all.len()).and_then(|i| all.get(i)) {
d.fields.insert("format".into(), json!(g.id));
}
ui.end_row();
@@ -76,8 +90,10 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::caption(ui, hint);
}
ui.add_space(10.0);
form::check(ui, d, "useArtboards", tl!("Use Artboards"));
let use_artboards = d.bool("useArtboards");
// An exporter plug-in writes the whole document: Use Artboards is not its to apply.
let whole = from_plugin(f);
ui.add_enabled_ui(!whole, |ui| form::check(ui, d, "useArtboards", tl!("Use Artboards")));
let use_artboards = d.bool("useArtboards") && !whole;
ui.add_enabled_ui(use_artboards, |ui| {
ui.horizontal(|ui| {
ui.add_space(22.0);
@@ -94,6 +110,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(8.0);
let ext = f.extensions[0];
let hint = match (use_artboards, f.id) {
_ if whole => tl!("The plug-in writes the whole document.").to_string(),
(true, "pdf") => tl!("One page per artboard.").to_string(),
(true, _) => crate::i18n::fmt(tl!("One file per artboard: <name>-<artboard>.{ext}"), &[("ext", ext)]),
(false, _) => tl!("The bounds of the visible art.").to_string(),
@@ -108,7 +125,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
return Err(hint.into());
}
let f = chosen(d).ok_or("no format to export to")?;
let use_artboards = d.bool("useArtboards");
let plugin = vectorcraft_plugins::export::by_format(f.id);
let use_artboards = d.bool("useArtboards") && plugin.is_none();
let all = d.bool("all");
let range = d.str("range");
if use_artboards && !all {
@@ -116,8 +134,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let n = app.session.active().map_or(0, |s| s.doc.artboards.len());
ArtboardPick { range: Some(range.clone()), ..Default::default() }.resolve(n)?;
}
// Nothing to export keeps the dialog open too (before the save dialog, not after it).
if !use_artboards && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) {
// Nothing to export keeps the dialog open too (before the save dialog, not after it). An
// exporter plug-in decides for itself what it can write.
if !use_artboards && plugin.is_none() && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) {
return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into());
}
app.ui.dialog = None;
@@ -142,6 +161,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if use_artboards {
params["range"] = json!(range);
}
if let Some(plugin) = plugin.filter(|p| !p.manifest().params.is_empty()) {
plugin::open_export(app, &plugin, params);
return Ok(Value::Null);
}
if f.raster {
png_options::open(app, f, params);
return Ok(Value::Null);
@@ -133,6 +133,10 @@ pub(crate) fn open_assets(app: &mut VectorcraftApp, checked: Option<&[u64]>) {
d.fields.insert("tab".into(), json!("assets"));
if let Some(c) = checked {
d.fields.insert("assets".into(), json!(c));
// The list scrolls to the first of them once: new assets land at its end.
if let Some(first) = c.first() {
d.fields.insert("__scrollTo".into(), json!(first));
}
}
}
}
@@ -544,9 +548,12 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
on.clear();
}
});
// Asked for until the row is in view: the dialog's first frame only measures it.
let scroll_to = d.fields.get("__scrollTo").and_then(Value::as_u64).filter(|id| st.doc.assets.iter().any(|a| a.id == *id));
let mut in_view = scroll_to.is_none();
egui::ScrollArea::vertical().max_height(330.0).show(ui, |ui| {
for a in &st.doc.assets {
ui.horizontal(|ui| {
let row = ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
crate::panels::asset_export::paint_thumb(ui, st, a, r);
let checked = on.contains(&a.id);
@@ -557,8 +564,18 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
}
});
if scroll_to == Some(a.id) {
if ui.clip_rect().contains_rect(row.response.rect) {
in_view = true;
} else {
row.response.scroll_to_me(Some(egui::Align::Center));
}
}
}
});
if in_view {
d.fields.remove("__scrollTo");
}
if on != before {
d.fields.insert("assets".into(), json!(on));
}
+191 -53
View File
@@ -3,6 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_plugins::{Manifest, Param, ParamSpec};
use crate::state::Dialog;
use crate::theme::Tokens;
@@ -369,64 +370,175 @@ fn typed_number(x: f64) -> Value {
if x.fract() == 0.0 && x.abs() < 1e15 { json!(x as i64) } else { json!(x) }
}
/// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers
/// within their range, whole numbers, checkboxes and dropdowns, in declaration order, a row each.
/// Returns true when a value changed. The labels (from the parameter keys) and choices are the
/// plug-in's text, shown as they are. A plug-in may declare more of them
/// Editor for a plug-in's parameters from its manifest (the filter, live effect and exporter
/// dialogs): numbers within their range, whole numbers, checkboxes, dropdowns, text and lists of
/// numbers, a row each. A row is labelled as the manifest says (else from the parameter's name),
/// with its unit after the value and its help on the label and on a help button that opens its
/// page. Parameters outside any group come first, then each group as a section that folds
/// ([`Manifest::sections`]); a parameter whose `visibleWhen` the values don't meet is left out
/// ([`Manifest::visible`]). Returns true when a value changed. Labels, choices, groups and help are
/// the plug-in's text, shown as they are. A plug-in may declare more parameters
/// ([`MAX_PARAMS`](vectorcraft_plugins::manifest::MAX_PARAMS)) than the window has room for, and
/// the rows then scroll ([`super::modal::scrolled_rows`]).
pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, vectorcraft_plugins::ParamSpec)]) -> bool {
use vectorcraft_plugins::ParamSpec;
let t = Tokens::get(ui.ctx());
let mut changed = false;
super::modal::scrolled_rows(ui, "plugin-fields", |ui| {
egui::Grid::new("plugin-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
for (k, spec) in specs {
ui.label(egui::RichText::new(humanize(k)).color(t.text));
let cur = d.fields.get(k).cloned().unwrap_or_else(|| spec.default_value());
let new = match spec {
ParamSpec::Number { min, max, .. } => {
let x = cur.as_f64().unwrap_or(*min);
crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, "", 2, 100.0).map(|x| json!(x))
}
ParamSpec::Int { min, max, .. } => {
let x = cur.as_i64().unwrap_or(*min).clamp(*min, *max);
// An f64 holds every whole number up to 2^53, so the numeric field (arrows, wheel, arithmetic) is exact there.
// Past it, the number typed is read as an integer.
const EXACT: i64 = 1 << 53;
if -EXACT <= *min && *max <= EXACT {
crate::widgets::range_field(ui, ("plugin-int", k), x as f64, *min as f64..=*max as f64, "", 0, 100.0)
.map(|x| json!(x as i64))
} else {
let shown = x.to_string();
crate::widgets::take_dialog_focus(ui, ui.id().with(("plugin-int", k)), &shown);
crate::widgets::text_field(ui, ("plugin-int", k), Some(&shown), 100.0, 1)
.and_then(|text| text.parse::<i64>().ok())
.map(|x| json!(x.clamp(*min, *max)))
}
}
ParamSpec::Bool { .. } => {
let b = cur.as_bool().unwrap_or(false);
crate::widgets::check_box(ui, &humanize(k), b, true).then(|| json!(!b))
}
ParamSpec::Choice { options, .. } => {
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0)
.and_then(|i| options.get(i))
.map(|o| json!(o))
}
};
if let Some(v) = new {
d.fields.insert(k.clone(), v);
changed = true;
}
ui.end_row();
}
});
});
pub(super) fn schema_fields(app: &mut crate::VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, m: &Manifest) -> bool {
let (changed, help) = schema_rows(ui, d, m);
if let Some(url) = help {
app.open_url(&url);
}
changed
}
/// [`schema_fields`] → whether a value changed, and the help page asked for, left to the caller to
/// open.
pub(super) fn schema_rows(ui: &mut egui::Ui, d: &mut Dialog, m: &Manifest) -> (bool, Option<String>) {
let mut changed = false;
let mut help = None;
let visible = m.visible(&d.fields);
let sections = m.sections();
// Every section's label column is as wide as the longest label shown, so the fields line up
// down the dialog; the rows outside groups make up for the indent of a group's rows.
let font = egui::TextStyle::Body.resolve(ui.style());
let label_width = (m.params.iter().filter(|(k, _)| visible.contains(k.as_str())))
.map(|(k, p)| ui.painter().layout_no_wrap(param_label(k, p), font.clone(), egui::Color32::WHITE).size().x)
.fold(0.0, f32::max);
let indent = if sections.iter().any(|(group, _)| group.is_some()) { ui.spacing().indent } else { 0.0 };
super::modal::scrolled_rows(ui, "plugin-fields", |ui| {
for (section, (group, params)) in sections.into_iter().enumerate() {
let shown: Vec<&(String, Param)> = params.into_iter().filter(|(k, _)| visible.contains(k.as_str())).collect();
if shown.is_empty() {
continue;
}
let rows = |ui: &mut egui::Ui, label_width: f32| {
let grid = egui::Grid::new(("plugin-grid", section)).num_columns(2).spacing([12.0, 8.0]).min_col_width(label_width);
grid.show(ui, |ui| {
for (k, p) in shown {
let (row_changed, opened) = param_row(ui, d, k, p);
changed |= row_changed;
help = help.take().or(opened);
ui.end_row();
}
});
};
match group {
None => rows(ui, label_width + indent),
Some(g) => {
egui::CollapsingHeader::new(g).id_salt(("plugin-group", g)).default_open(true).show(ui, |ui| rows(ui, label_width));
}
}
}
});
(changed, help)
}
/// The label of parameter `k`'s row: its manifest `label`, else its name spaced out.
fn param_label(k: &str, p: &Param) -> String {
p.ui.label.as_ref().map_or_else(|| humanize(k), |l| format!("{l}:"))
}
/// One row of [`schema_fields`]: parameter `k`'s label and value field → whether the value changed,
/// and the help page asked for.
fn param_row(ui: &mut egui::Ui, d: &mut Dialog, k: &str, p: &Param) -> (bool, Option<String>) {
let t = Tokens::get(ui.ctx());
let label = param_label(k, p);
let shown_label = ui.label(egui::RichText::new(&label).color(t.text));
if let Some(help) = &p.ui.help {
shown_label.on_hover_text(help);
}
let cur = d.fields.get(k).cloned().unwrap_or_else(|| p.spec.default_value());
let unit = p.ui.unit.as_deref().unwrap_or_default();
let suffix = unit_suffix(unit);
let mut opened = None;
let new = ui
.horizontal(|ui| {
let (new, in_field) = value_field(ui, k, &label, &p.spec, &cur, &suffix);
if !in_field && !unit.is_empty() {
ui.label(egui::RichText::new(unit).color(t.text_dim));
}
if let Some(url) = &p.ui.help_url
&& crate::widgets::icon_button(ui, "external-link", tl!("Open this setting's help page"), false, 20.0).clicked()
{
opened = Some(url.clone());
}
new
})
.inner;
let changed = new.map(|v| d.fields.insert(k.to_string(), v)).is_some();
(changed, opened)
}
/// The field of parameter `k`'s value `cur` (a checkbox is called by the row's `label`) → the new
/// value when it changed, and whether the field shows `suffix` (numbers do; the other fields leave
/// the unit to a label after them).
fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: &Value, suffix: &str) -> (Option<Value>, bool) {
match spec {
ParamSpec::Number { min, max, .. } => {
let x = cur.as_f64().unwrap_or(*min);
(crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, suffix, 2, 100.0).map(|x| json!(x)), true)
}
ParamSpec::Int { min, max, .. } => {
let x = cur.as_i64().unwrap_or(*min).clamp(*min, *max);
// An f64 holds every whole number up to 2^53, so the numeric field (arrows, wheel, arithmetic) is exact there.
// Past it, the number typed is read as an integer.
const EXACT: i64 = 1 << 53;
if -EXACT <= *min && *max <= EXACT {
let field = crate::widgets::range_field(ui, ("plugin-int", k), x as f64, *min as f64..=*max as f64, suffix, 0, 100.0);
(field.map(|x| json!(x as i64)), true)
} else {
let shown = x.to_string();
crate::widgets::take_dialog_focus(ui, ui.id().with(("plugin-int", k)), &shown);
let typed = crate::widgets::text_field(ui, ("plugin-int", k), Some(&shown), 100.0, 1);
(typed.and_then(|text| text.parse::<i64>().ok()).map(|x| json!(x.clamp(*min, *max))), false)
}
}
ParamSpec::Bool { .. } => {
let b = cur.as_bool().unwrap_or(false);
(crate::widgets::check_box(ui, label, b, true).then(|| json!(!b)), false)
}
ParamSpec::Choice { options, .. } => {
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
let chosen = crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0);
(chosen.and_then(|i| options.get(i)).map(|o| json!(o)), false)
}
ParamSpec::Text { max_length, .. } => {
let typed = crate::widgets::text_field(ui, ("plugin-text", k), cur.as_str(), 160.0, 1);
(typed.map(|text| json!(text.chars().take(*max_length).collect::<String>())), false)
}
ParamSpec::NumberList { .. } => {
let typed = crate::widgets::text_field(ui, ("plugin-list", k), Some(&number_list_text(cur)), 160.0, 1);
(typed.and_then(|text| read_number_list(&text, spec)), false)
}
}
}
/// A `numberList` value as its field shows it: "45, 135".
pub(crate) fn number_list_text(cur: &Value) -> String {
let numbers: Vec<String> = cur.as_array().into_iter().flatten().filter_map(Value::as_f64).map(number_text).collect();
numbers.join(", ")
}
/// The list typed in a `numberList` parameter's field: numbers separated by commas, semicolons or
/// spaces, each clamped into the parameter's range. `None` (the value stays as it was) when a
/// number doesn't read, there are too few or too many, or `spec` isn't a number list.
pub(crate) fn read_number_list(text: &str, spec: &ParamSpec) -> Option<Value> {
let ParamSpec::NumberList { min, max, min_items, max_items, .. } = spec else { return None };
let parts = text.split([',', ';', ' ']).filter(|p| !p.trim().is_empty());
let list: Vec<f64> = parts.map(|p| vectorcraft_doc::parse_number(p).filter(|x| x.is_finite())).collect::<Option<_>>()?;
(*min_items..=*max_items).contains(&list.len()).then(|| json!(list.into_iter().map(|x| x.clamp(*min, *max)).collect::<Vec<f64>>()))
}
/// What a number field shows after its value for a plug-in parameter's `unit` (none: nothing): a
/// sign such as ° or % joins the number ("45°"), and any other unit is spaced from it ("0.4 mm",
/// "5 1/min"), so a unit starting with a digit isn't read as more of the number.
pub(crate) fn unit_suffix(unit: &str) -> String {
if unit.is_empty() || unit.starts_with(['°', '%', '′', '″']) { unit.to_string() } else { format!(" {unit}") }
}
/// A number of a list as [`value_field`] shows it: at most 4 decimals, without trailing zeros.
fn number_text(x: f64) -> String {
let s = format!("{x:.4}");
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
}
/// A field label from its camelCase key (`miterLimit` → "Miter Limit:").
pub(crate) fn humanize(k: &str) -> String {
let mut s = String::new();
@@ -453,6 +565,7 @@ pub(crate) fn humanize(k: &str) -> String {
"Channel2" => "Channel 2:".into(),
"Channel3" => "Channel 3:".into(),
"Channel4" => "Channel 4:".into(),
"Light Dark Balance" => "Light/Dark Balance:".into(),
"Include Cmy Blacks" => "Include Blacks with CMY:".into(),
"Align To Path" => "Align to Path:".into(),
"Overlap Variation" | "Curviness Variation" | "Spacing Variation" => "Variation:".into(),
@@ -714,6 +827,31 @@ pub(super) fn caption(ui: &mut egui::Ui, text: &str) {
ui.label(egui::RichText::new(tl!(text)).size(11.0).color(t.text_dim));
}
#[cfg(test)]
mod plugin_field_tests {
use super::*;
/// A unit reads after its number as it is written: a sign joins it ("45°", "50%"), anything
/// else is spaced, so a unit starting with a digit isn't read as more of the number.
#[test]
fn units_follow_the_number() {
let shown = |unit| format!("5{}", unit_suffix(unit));
assert_eq!([shown("mm"), shown("°"), shown("%"), shown("1/min"), shown("µm")], ["5 mm", "5°", "5%", "5 1/min", "5 µm"]);
}
#[test]
fn number_lists_read_as_typed_within_their_limits() {
let spec = ParamSpec::NumberList { min: 0.0, max: 360.0, default: vec![45.0], min_items: 1, max_items: 3 };
assert_eq!(number_list_text(&json!([45.0, 135.5, 0.125])), "45, 135.5, 0.125");
assert_eq!(read_number_list("10; 400 20", &spec), Some(json!([10.0, 360.0, 20.0])), "separators, and clamped");
assert_eq!(read_number_list(" 90 , 2*45 ", &spec), Some(json!([90.0, 90.0])));
for bad in ["", "1, 2, 3, 4", "1, x", "1, inf"] {
assert_eq!(read_number_list(bad, &spec), None, "{bad:?}");
}
assert_eq!(read_number_list("1", &ParamSpec::Bool { default: false }), None, "not a list");
}
}
#[cfg(test)]
mod typed_number_tests {
use super::*;
+6 -1
View File
@@ -120,7 +120,7 @@ pub(crate) use export_for_screens::{
KIND as EXPORT_FOR_SCREENS, formats as screen_formats, open_assets as open_export_for_screens_assets, saved_options as screen_saved_options,
saved_rows as screen_saved_rows,
};
pub(crate) use form::humanize;
pub(crate) use form::{humanize, number_list_text, read_number_list, unit_suffix};
pub(crate) use new_document::preset_name;
pub use new_document::{open as open_new_document, preset_card};
pub use png_options::open as open_raster_options;
@@ -324,6 +324,7 @@ registry! {
PrintPreset: [print::PRESET_KIND] => print::PRESET_SPEC,
PrintPresets: [print_presets::KIND] => print_presets::SPEC,
Plugin: [plugin::KIND] => plugin::SPEC,
PluginExport: [plugin::EXPORT_KIND] => plugin::EXPORT_SPEC,
ScriptCheck: [script_check::KIND] => script_check::SPEC,
VectorHalftone: [halftone::KIND] => halftone::SPEC,
SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC,
@@ -577,6 +578,10 @@ mod tests_import_pdf;
#[cfg(test)]
mod tests_package;
#[cfg(test)]
mod tests_plugin_export;
#[cfg(test)]
mod tests_plugin_fields;
#[cfg(test)]
mod tests_raster_formats;
#[cfg(test)]
mod tests_svg_options_range;
+70 -15
View File
@@ -5,10 +5,13 @@
//! effect dialog (`effect.dialog`). A filter with parameters opens dialog `plugin`: one field per
//! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live
//! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs
//! at once.
//! at once. Exporters are formats of File › Export › Export As; one with parameters opens dialog
//! `pluginExport` once the file is picked, and its Export button writes the file.
use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_plugins::{Kind, registry};
use vectorcraft_plugins::{Kind, Manifest, Plugin, registry};
use super::{DialogSpec, form};
use crate::VectorcraftApp;
@@ -70,34 +73,49 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let id = p.get("id").and_then(Value::as_str).ok_or("missing `id` (see plugin.list)")?;
let plugin = registry::get(id).ok_or_else(|| format!("no plug-in {id:?} is installed"))?;
let m = plugin.manifest();
if m.kind != Kind::Filter {
return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name));
match m.kind {
Kind::Filter => {}
Kind::Effect => return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name)),
Kind::Exporter => return Err(format!("{} is an exporter (File › Export › Export As…)", m.name)),
}
let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty()));
if m.params.is_empty() || given.is_some() {
return app.run(CMD, json!({"id": id, "params": given.cloned().unwrap_or(Value::Null)}));
}
let mut fields: Map<String, Value> = m.defaults();
fields.insert("__plugin".into(), json!(id));
fields.insert("__label".into(), json!(m.name.trim_end_matches('…')));
let mut fields = fields_for(m);
fields.insert("preview".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
Ok(Value::Null)
}
/// The fields of plug-in `m`'s dialog: its parameters at their defaults, `__plugin` (its id) and
/// `__label` (its name, the heading).
fn fields_for(m: &Manifest) -> Map<String, Value> {
let mut fields = m.defaults();
fields.insert("__plugin".into(), json!(m.id));
fields.insert("__label".into(), json!(m.name.trim_end_matches('…')));
fields
}
/// The plug-in dialog `d` is for, its description drawn above the fields. `None` once it was
/// uninstalled, which closes the dialog.
fn described(ui: &mut egui::Ui, d: &Dialog) -> Option<Arc<Plugin>> {
let plugin = registry::get(&d.str("__plugin"))?;
let m = plugin.manifest();
if !m.description.is_empty() {
ui.label(egui::RichText::new(&m.description).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
ui.add_space(8.0);
}
Some(plugin)
}
fn params(d: &Dialog) -> Value {
json!({"id": d.str("__plugin"), "params": form::params(d)})
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
// A plug-in uninstalled while its dialog is open closes it.
let Some(plugin) = registry::get(&d.str("__plugin")) else { return true };
let m = plugin.manifest();
if !m.description.is_empty() {
ui.label(egui::RichText::new(&m.description).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
ui.add_space(8.0);
}
form::schema_fields(ui, d, &m.params);
let Some(plugin) = described(ui, d) else { return true };
form::schema_fields(app, ui, d, plugin.manifest());
let (label, p) = (d.str("__label"), params(d));
form::preview(app, ui, d, &label, CMD, p);
false
@@ -106,3 +124,40 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
form::commit_preview(app, CMD, params(d))
}
/// The dialog kind of an exporter's parameters, after Export As picked the file.
pub const EXPORT_KIND: &str = "pluginExport";
/// Headed by the plug-in's name, as it is; Export writes the file.
pub(super) const EXPORT_SPEC: DialogSpec = DialogSpec {
heading: |d| d.str("__label"),
body: export_body,
confirm: export_confirm,
ok: Some("Export"),
max_width: Some(420.0),
..DialogSpec::FORM
};
/// Export As with exporter `plugin`, which has parameters: its fields at their defaults, and
/// `export`, the export's own parameters (`format`, `path`, …), kept for Export.
pub fn open_export(app: &mut VectorcraftApp, plugin: &Plugin, export: Value) {
let mut fields = fields_for(plugin.manifest());
fields.insert("__export".into(), export);
app.ui.dialog = Some(Dialog { kind: EXPORT_KIND.into(), fields });
}
fn export_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let Some(plugin) = described(ui, d) else { return true };
form::schema_fields(app, ui, d, plugin.manifest());
false
}
/// Writes the file with the export's parameters and the plug-in's, as `params`.
fn export_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = d.fields.get("__export").cloned().filter(Value::is_object).ok_or("no format to export to")?;
p["params"] = form::params(d);
let format = p.get("format").and_then(Value::as_str).map(str::to_string);
let path = p.get("path").and_then(Value::as_str).map(str::to_string);
app.ui.dialog = None;
crate::io::export(app, format.as_deref(), path, &p)
}
+55 -3
View File
@@ -2,10 +2,13 @@
//! Star, Line Segment Tool Options and the two grids (size, divider counts and skews, switches).
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use super::{DialogSpec, form, run_and_close};
use crate::VectorcraftApp;
use crate::panels::transform::artboard_rect;
use crate::state::Dialog;
use crate::widgets;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, ..DialogSpec::FORM };
@@ -27,9 +30,57 @@ fn grid_keys(kind: &str) -> ([&'static str; 2], [&'static str; 2]) {
if kind == "polarGrid" { (["concentric", "radial"], ["concentricSkew", "radialSkew"]) } else { (["rows", "columns"], ["rowSkew", "columnSkew"]) }
}
/// The 9-point locator index for a grid dialog (`reference`, default top-left).
fn grid_reference(d: &Dialog) -> usize {
d.fields.get("reference").and_then(|v| v.as_u64()).map(|n| (n as usize).min(8)).unwrap_or(0)
}
/// A grid dialog's bounding box from its top-left `x`/`y` and size fields.
fn grid_placed(d: &Dialog) -> Rect {
let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0));
Rect::new(x, y, x + d.f64("width", 100.0), y + d.f64("height", 100.0))
}
fn set_grid_placed(d: &mut Dialog, r: Rect) {
d.fields.insert("x".into(), json!(r.x0));
d.fields.insert("y".into(), json!(r.y0));
d.fields.insert("width".into(), json!(r.width()));
d.fields.insert("height".into(), json!(r.height()));
}
/// Top-left `x`/`y` so reference point `refi` sits on the canvas click (`__anchorX`/`__anchorY`).
pub(crate) fn grid_pin_to_anchor(d: &mut Dialog) {
let anchor = Point::new(d.f64("__anchorX", d.f64("x", 0.0)), d.f64("__anchorY", d.f64("y", 0.0)));
let refi = grid_reference(d);
let (w, h) = (d.f64("width", 100.0), d.f64("height", 100.0));
let rp = vectorcraft_geom::reference_point(Rect::new(0.0, 0.0, w, h), refi);
d.fields.insert("x".into(), json!(anchor.x - rp.x));
d.fields.insert("y".into(), json!(anchor.y - rp.y));
}
/// The form's fields, then a grid's switches.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
form::grid(ui, d, app.session.general_unit());
let unit = app.session.general_unit();
if matches!(d.kind.as_str(), "rectangularGrid" | "polarGrid") {
ui.horizontal(|ui| {
let refi = grid_reference(d);
if let Some(i) = widgets::reference_point(ui, refi) {
d.fields.insert("reference".into(), json!(i));
grid_pin_to_anchor(d);
}
});
ui.add_space(4.0);
let refi = grid_reference(d);
let (was, w0, h0) = (grid_placed(d), d.f64("width", 100.0), d.f64("height", 100.0));
form::grid_then(ui, d, unit, |_, d| {
let (w, h) = (d.f64("width", 100.0), d.f64("height", 100.0));
if (w - w0).abs() > 1e-9 || (h - h0).abs() > 1e-9 {
set_grid_placed(d, artboard_rect(was, refi, None, None, Some(w), Some(h), false));
}
});
} else {
form::grid(ui, d, unit);
}
let own = match d.kind.as_str() {
"rectangularGrid" => ("frame", tl!("Use Outside Rectangle As Frame")),
"polarGrid" => ("compound", tl!("Create Compound Path From Ellipses")),
@@ -54,8 +105,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
kind @ ("rectangularGrid" | "polarGrid") => {
// The switches as they are; the typed fields as numbers.
let (mut p, (counts, skews)) = (form::params(d), grid_keys(kind));
for (k, default) in [("width", 100.0), ("height", 100.0)] {
p[k] = json!(d.f64(k, default));
let r = grid_placed(d);
for (k, v) in [("x", r.x0), ("y", r.y0), ("width", r.width()), ("height", r.height())] {
p[k] = json!(v);
}
for (count, skew) in counts.into_iter().zip(skews) {
p[count] = json!((d.f64(count, 5.0) as u64).min(999));
+88 -5
View File
@@ -202,6 +202,9 @@ fn raster_filter_dialogs_preview_and_apply() {
("artistic.cutout", "levels", "6"),
("artistic.neonGlow", "color", "#ff00ff"),
("artistic.posterEdges", "edgeThickness", "5"),
("artistic.smudgeStick", "strokeLength", "6"),
("artistic.sponge", "definition", "20"),
("artistic.watercolor", "texture", "2"),
("blur.radial", "method", "zoom"),
("blur.smart", "quality", "high"),
("blur.smart", "blurMode", "overlayEdge"),
@@ -210,7 +213,12 @@ fn raster_filter_dialogs_preview_and_apply() {
("pixelate.crystallize", "cellSize", "20"),
("pixelate.mezzotint", "type", "longLines"),
("pixelate.pointillize", "cellSize", "12"),
("sketch.basRelief", "lightDirection", "left"),
("sketch.charcoal", "thickness", "5"),
("sketch.halftonePattern", "patternType", "line"),
("sketch.photocopy", "darkness", "40"),
("sketch.stamp", "lightDarkBalance", "10"),
("sketch.plaster", "lightDirection", "bottomLeft"),
("texture.craquelure", "crackDepth", "9"),
("texture.grain", "grainType", "stippled"),
("texture.mosaicTiles", "tileSize", "20"),
@@ -514,7 +522,7 @@ fn given_headings_are_shown_as_they_are() {
struct PluginIntField {
ctx: egui::Context,
d: Dialog,
specs: Vec<(String, vectorcraft_plugins::ParamSpec)>,
m: vectorcraft_plugins::Manifest,
pos: egui::Pos2,
}
@@ -523,8 +531,17 @@ impl PluginIntField {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let specs = vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 })];
let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), specs, pos: egui::Pos2::ZERO };
let m = vectorcraft_plugins::Manifest {
id: "test.seed".into(),
name: "Seed".into(),
version: String::new(),
kind: vectorcraft_plugins::Kind::Filter,
description: String::new(),
author: String::new(),
params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())],
extensions: Vec::new(),
};
let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO };
fn number_rect(shape: &egui::Shape, text: &str) -> Option<egui::Rect> {
match shape {
egui::Shape::Text(t) if t.galley.text() == text => Some(t.visual_bounding_rect()),
@@ -538,9 +555,9 @@ impl PluginIntField {
}
fn run(&mut self, events: Vec<egui::Event>) -> egui::FullOutput {
let (d, specs) = (&mut self.d, &self.specs);
let (d, m) = (&mut self.d, &self.m);
let mut out = self.ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
form::schema_fields(ui, d, specs);
form::schema_rows(ui, d, m);
});
out.textures_delta.clear();
out
@@ -1023,6 +1040,72 @@ fn artboard_options_position_follows_the_tool_option_with_another_tool() {
}
}
/// #1482: with Artboards 1 and 3 selected with the Artboard tool, a new Width in Artboard Options
/// goes to both, each keeping its top-left corner and its height, in one undo step. The new name
/// goes only to the artboard the dialog shows, and Artboard 2 keeps its size. A name alone resizes
/// nothing.
#[test]
fn artboard_options_width_resizes_every_selected_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
app.run("artboard.setProps", json!({"index": 2, "width": 300, "height": 150})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 2]));
let boards = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards.clone();
let before = boards(&app);
let undo = app.session.doc().unwrap().history.undo.len();
crate::dialogs::artboard_options::open(&mut app).unwrap();
let fields = &mut app.ui.dialog.as_mut().unwrap().fields;
fields.insert("width".into(), json!("250 pt"));
fields.insert("name".into(), json!("Cover"));
frame(&mut app, Default::default());
super::confirm(&mut app).unwrap();
let mut want = before.clone();
want[0].name = "Cover".into();
for i in [0, 2] {
want[i].rect.x1 = want[i].rect.x0 + 250.0;
}
assert_eq!(boards(&app), want);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(boards(&app), before);
crate::dialogs::artboard_options::open(&mut app).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Cover"));
frame(&mut app, Default::default());
super::confirm(&mut app).unwrap();
want = before.clone();
want[0].name = "Cover".into();
assert_eq!(boards(&app), want, "a name alone");
}
/// #1482 with #1876: a Width typed in Artboard Options resizes each selected artboard about the
/// reference point the form uses, the center by default, as the Transform panel's W does.
#[test]
fn artboard_options_width_resizes_every_selected_artboard_about_the_reference_point() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300, "artboards": 2})).unwrap();
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 0);
app.session.set_tool_option("selected", &json!([0, 1]));
let before: Vec<vectorcraft_geom::Rect> = app.session.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
crate::dialogs::artboard_options::open(&mut app).unwrap();
let mut f = Frames::new();
let shown = f.texts(&mut app);
let (width, height) = (field_after(&shown, "Width:"), field_after(&shown, "Height:"));
f.press(&mut app, width, true);
f.press(&mut app, width, false);
f.run(&mut app, vec![egui::Event::Text("500".into())]);
// A click on Height ends the Width field's editing.
f.press(&mut app, height, true);
f.press(&mut app, height, false);
f.key(&mut app, egui::Key::Enter, egui::Modifiers::NONE);
assert!(app.ui.dialog.is_none(), "Enter applies the dialog");
let after: Vec<vectorcraft_geom::Rect> = app.session.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
let want: Vec<vectorcraft_geom::Rect> = before.iter().map(|r| vectorcraft_geom::Rect::new(r.x0 - 50.0, r.y0, r.x1 + 50.0, r.y1)).collect();
assert_eq!(after, want);
}
/// The strings a frame painted, each with its rectangle.
fn painted(out: &egui::FullOutput) -> Vec<(String, egui::Rect)> {
fn walk(s: &egui::Shape, v: &mut Vec<(String, egui::Rect)>) {
+4 -3
View File
@@ -144,12 +144,13 @@ fn check_plugin_dialog(kind: &str, count: usize, check: impl FnOnce(&egui::Conte
/// settings is as tall as they are.
#[test]
fn a_plug_in_dialog_of_many_settings_fits_the_window() {
use vectorcraft_plugins::manifest::MAX_PARAMS;
// As many as a plug-in may declare outside groups.
use vectorcraft_plugins::manifest::MAX_UNGROUPED;
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
let shown = |texts: &[(String, Rect)], text: &str| texts.iter().find(|(t, r)| t == text && screen.contains_rect(*r)).map(|(_, r)| *r);
for kind in ["effect", "filter"] {
check_plugin_dialog(kind, MAX_PARAMS, |ctx, app, window, heading| {
let last = format!("Setting{}:", MAX_PARAMS - 1);
check_plugin_dialog(kind, MAX_UNGROUPED, |ctx, app, window, heading| {
let last = format!("Setting{}:", MAX_UNGROUPED - 1);
let texts = frame(ctx, app, vec![]);
let rect = rect_of(ctx, window);
assert!(screen.contains_rect(rect) && rect.height() > 0.75 * SCREEN.y, "{kind}: the dialog is at {rect:?} in the {SCREEN:?} window");
@@ -0,0 +1,116 @@
//! Exporter plug-ins in File › Export › Export As: listed after the built-in formats, written
//! whole (Use Artboards is not theirs to apply), and their parameters asked for once the file is
//! picked. Each test installs exporters of its own: plug-ins are process-wide.
use serde_json::{Value, json};
use vectorcraft_plugins::wat::{self as tpl, Echo};
use super::tests_export::{app, frame, kind, names, set};
use super::*;
fn install(app: &mut VectorcraftApp, src: &str) {
let wasm = wat::parse_str(src).unwrap();
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap();
}
#[test]
fn export_as_lists_exporters_and_writes_their_file_whole() {
let (mut app, written) = app(2);
install(&mut app, &tpl::returning(&tpl::exporter("test.ui.whole", r#"["tuw"]"#, "{}"), r#"{"dataBase64":"SGk=","warnings":["a note"]}"#));
// After the built-in formats, with the other exporters installed.
let listed: Vec<&str> = export_as::formats().iter().map(|f| f.id).collect();
let first = listed.iter().position(|id| id.starts_with("plugin.")).expect("an exporter listed");
let added = listed.get(first..).unwrap_or_default();
assert!(added.iter().all(|id| id.starts_with("plugin.")) && added.contains(&"plugin.test.ui.whole"), "{listed:?}");
// It isn't a filter: Object › Plug-ins' command says where it is.
let e = app.run("plugin.dialog", json!({"id": "test.ui.whole"})).unwrap_err();
assert!(e.contains("is an exporter (File › Export › Export As…)"), "{e}");
app.run("file.exportAs", json!({"format": "plugin.test.ui.whole"})).unwrap();
assert_eq!(kind(&app), Some("exportAs"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "plugin.test.ui.whole");
// Use Artboards is greyed out and doesn't apply: one file of the whole document.
set(&mut app, "useArtboards", json!(true));
frame(&mut app);
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "an exporter without parameters writes at once");
assert_eq!(names(&written), ["/out/Untitled-1.tuw"]);
assert_eq!(written.borrow()[0].1, b"Hi");
assert!(app.ui.status.contains("a note"), "{}", app.ui.status);
}
#[test]
fn an_exporter_with_parameters_asks_for_them_once_the_file_is_picked() {
let (mut app, written) = app(1);
let params = r#"{"density":{"type":"number","min":0,"max":10,"default":4}}"#;
install(&mut app, &tpl::echo_exporter(&tpl::exporter("test.ui.params", r#"["tup"]"#, params), Echo::Params));
// Asked for by its extension, as agents may.
app.run("file.exportAs", json!({"format": "tup"})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "plugin.test.ui.params");
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some(plugin::EXPORT_KIND));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("__label"), d.fields.get("density")), ("Test test.ui.params".to_string(), Some(&json!(4.0))));
assert!(written.borrow().is_empty(), "nothing written before Export");
frame(&mut app);
set(&mut app, "density", json!(7));
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(names(&written), ["/out/Untitled-1.tup"]);
let sent: Value = serde_json::from_slice(&written.borrow()[0].1).unwrap();
assert_eq!(sent, json!({"density": 7.0, "_context": {"mode": "export", "extension": "tup", "name": "Untitled-1"}}));
}
/// `CRAFT_UI_PLUGIN_EXPORT_SHOT=<folder>` saves the pictures of the dialogs to that folder: Export As
/// with an exporter's format chosen, then the exporter's own dialog. The plug-in is a test one with
/// parameters a plotter's exporter could have, saved beside them (`plotter-test-exporter.wasm`) to
/// install in the app.
#[test]
fn export_as_and_exporter_dialog_pictures() {
let Some(dir) = std::env::var_os("CRAFT_UI_PLUGIN_EXPORT_SHOT") else { return };
let dir = std::path::PathBuf::from(dir);
let (mut app, _) = app(1);
let params = r#"{"speed":{"type":"number","min":1,"max":100,"default":40},"passes":{"type":"int","min":1,"max":10,"default":1},
"optimizeTravel":{"type":"bool","default":true},"paper":{"type":"choice","options":["A4","A3","Letter"],"default":"A4"}}"#;
let manifest = r#"{"id":"org.example.plotter","name":"Plotter (test exporter)","version":"1","kind":"exporter","extensions":["plt"],
"description":"Writes the document as pen plotter commands.","params":PARAMS}"#
.replace("PARAMS", params);
let src = tpl::echo_exporter(&manifest, Echo::Params);
std::fs::write(dir.join("plotter-test-exporter.wasm"), wat::parse_str(&src).unwrap()).unwrap();
install(&mut app, &src);
app.run("file.exportAs", json!({"format": "plugin.org.example.plotter"})).unwrap();
let shot = |app: VectorcraftApp, name: &str| -> VectorcraftApp {
let mut ready = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(720.0, 520.0)).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !ready {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark);
ready = true;
return;
}
show(app, ui.ctx());
},
app,
);
h.run_steps(6);
h.render().unwrap().save(dir.join(name)).unwrap();
h.into_state()
};
app = shot(app, "export-as-exporter.png");
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some(plugin::EXPORT_KIND));
shot(app, "exporter-options.png");
}
/// A plug-in that declines keeps nothing written and says why.
#[test]
fn a_declining_exporter_writes_nothing() {
let (mut app, written) = app(1);
install(&mut app, &tpl::returning(&tpl::exporter("test.ui.declines", r#"["tud"]"#, "{}"), r#"{"error":"Nothing to plot."}"#));
app.run("file.exportAs", json!({"format": "plugin.test.ui.declines"})).unwrap();
let r = confirm(&mut app);
assert!(written.borrow().is_empty());
let said = r.err().unwrap_or_default() + &app.ui.status;
assert!(said.contains("Nothing to plot."), "{said}");
}
@@ -0,0 +1,171 @@
//! A plug-in's parameters in its dialog as its manifest presents them (docs/plugins.md ›
//! Parameters): labels, units after the values, help on the labels and a button to its page,
//! sections by group, text and lists of numbers, and parameters shown only while their
//! `visibleWhen` holds. The plug-in is removed after each test: plug-ins are process-wide.
use std::sync::{Arc, Mutex};
use egui::vec2;
use egui_kittest::kittest::Queryable;
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use vectorcraft_plugins::wat as tpl;
use super::*;
/// A filter whose parameters use every presentation key.
fn manifest(id: &str) -> String {
format!(
r#"{{"id":"{id}","name":"Hatch Test","version":"1","kind":"filter","params":{{
"outline":{{"type":"bool","default":true,"label":"Outline"}},
"title":{{"type":"string","default":"Poster","maxLength":8,"label":"Title"}},
"pattern":{{"type":"choice","options":["lines","crosshatch","dots"],"default":"lines","label":"Pattern",
"group":"Fill","help":"How the shape is filled."}},
"spacing":{{"type":"number","min":0.1,"max":10,"default":0.25,"label":"Spacing","unit":"mm","group":"Fill",
"helpUrl":"https://example.org/params/spacing","visibleWhen":{{"pattern":["lines","crosshatch"]}}}},
"angles":{{"type":"numberList","min":0,"max":360,"default":[45,135],"minItems":1,"maxItems":4,"label":"Angles",
"unit":"°","group":"Fill","visibleWhen":{{"pattern":["lines","crosshatch"]}}}},
"width":{{"type":"number","min":0,"max":10,"default":0.5,"label":"Width","unit":"pt","group":"Outline",
"visibleWhen":{{"outline":[true]}}}}}}}}"#
)
}
/// The dialog of plug-in `id` in a kittest harness; the web pages it opens go to `opened`.
fn harness(id: &str, opened: Arc<Mutex<Vec<String>>>) -> Harness {
let services = crate::Services { open_url: Some(Box::new(move |u: &str| opened.lock().unwrap().push(u.to_string()))), ..Default::default() };
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let wasm = wat::parse_str(tpl::module(&manifest(id), tpl::ECHO, "", 1)).unwrap();
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap();
app.run("plugin.dialog", json!({"id": id})).unwrap();
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 800.0)).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut fonts, true) {
crate::theme::install_fonts(ui.ctx());
return;
}
show(app, ui.ctx());
},
app,
);
h.run_steps(4);
h
}
type Harness = egui_kittest::Harness<'static, VectorcraftApp>;
/// The text label `text` (a checkbox is called by its row's label too).
fn label<'h>(h: &'h Harness, text: &'h str) -> Option<egui_kittest::Node<'h>> {
h.query_by_role_and_label(egui::accesskit::Role::Label, text)
}
fn set(h: &mut Harness, key: &str, value: Value) {
h.state_mut().ui.dialog.as_mut().expect("the dialog is open").fields.insert(key.into(), value);
h.run_steps(2);
}
fn remove(h: &mut Harness, id: &str) {
h.state_mut().run("plugin.remove", json!({"id": id})).unwrap();
}
#[test]
fn labels_units_sections_text_and_lists_come_from_the_manifest() {
let id = "test.ui.fields.shown";
let mut h = harness(id, Arc::default());
for text in ["Outline:", "Title:", "Pattern:", "Spacing:", "Angles:", "Width:", "°"] {
assert!(label(&h, text).is_some(), "{text} isn't shown");
}
for section in ["Fill", "Outline"] {
assert!(h.query_by_label(section).is_some(), "the {section} section isn't shown");
}
for value in ["Poster", "0.25 mm", "45, 135", "0.5 pt"] {
// A text field and the text inside it both carry the value.
assert!(h.query_all_by_value(value).next().is_some(), "the value {value} isn't shown");
}
// The parameters outside groups come first, then each group's section.
let top = |text: &str| label(&h, text).map(|n| n.rect().top()).unwrap_or_default();
let (fill, outline) = (h.get_by_label("Fill").rect().top(), h.get_by_label("Outline").rect().top());
assert!(top("Title:") < fill && fill < top("Spacing:") && top("Angles:") < outline && outline < top("Width:"));
// The fields line up down the dialog, in sections and out of them.
let left = |value: &str| h.query_all_by_value(value).next().map(|n| n.rect().left()).unwrap_or_default();
let (title, spacing, width) = (left("Poster"), left("0.25 mm"), left("0.5 pt"));
assert!((title - spacing).abs() < 0.5 && (spacing - width).abs() < 0.5, "fields at {title}, {spacing} and {width}");
remove(&mut h, id);
}
#[test]
fn visible_when_shows_a_parameter_only_while_its_condition_holds() {
let id = "test.ui.fields.visible";
let mut h = harness(id, Arc::default());
set(&mut h, "pattern", json!("dots"));
assert!(label(&h, "Spacing:").is_none() && label(&h, "Angles:").is_none(), "dots have no line spacing");
set(&mut h, "outline", json!(false));
assert!(label(&h, "Width:").is_none() && h.query_by_label("Outline").is_none(), "without an outline, nor its section");
set(&mut h, "pattern", json!("crosshatch"));
assert!(label(&h, "Spacing:").is_some());
// A hidden parameter still goes to the plug-in: the preview runs with every value.
let fields = &h.state().ui.dialog.as_ref().unwrap().fields;
assert_eq!(fields.get("width"), Some(&json!(0.5)));
remove(&mut h, id);
}
#[test]
fn the_help_button_opens_the_parameter_s_page() {
let id = "test.ui.fields.help";
let opened: Arc<Mutex<Vec<String>>> = Arc::default();
let mut h = harness(id, opened.clone());
let buttons = h.query_all_by_label("Open this setting's help page").count();
assert_eq!(buttons, 1, "only Spacing has a page");
h.get_by_label("Open this setting's help page").click();
h.run_steps(2);
assert_eq!(*opened.lock().unwrap(), ["https://example.org/params/spacing"]);
remove(&mut h, id);
}
/// `CRAFT_UI_PLUGIN_FIELDS_SHOT=<folder>` saves a picture of a dialog whose parameters use every
/// presentation key there, and the test plug-in beside it (`hatch-test-filter.wasm`) to install in
/// the app. Its parameter names are written as many plug-ins write them, in snake case, and it uses
/// only the types every host reads, so a host from before the keys installs it too and shows it as
/// it did.
#[test]
fn plugin_fields_picture() {
let Some(dir) = std::env::var_os("CRAFT_UI_PLUGIN_FIELDS_SHOT") else { return };
let dir = std::path::PathBuf::from(dir);
let manifest = r#"{"id":"org.example.hatch","name":"Hatch Fill (test plug-in)","version":"1","kind":"filter",
"description":"Fills the selected shapes with lines.","params":{
"pattern":{"type":"choice","options":["lines","crosshatch","dots"],"default":"lines",
"label":"Pattern","help":"How the shapes are filled."},
"line_angle":{"type":"number","min":0,"max":360,"default":45,"label":"Angle","unit":"°","group":"Lines",
"help":"The direction of the lines.","visibleWhen":{"pattern":["lines","crosshatch"]}},
"line_spacing_mm":{"type":"number","min":0.1,"max":10,"default":1.5,"label":"Spacing","unit":"mm","group":"Lines",
"help":"Distance between the lines. Smaller is denser.","helpUrl":"https://example.org/params/line_spacing_mm",
"visibleWhen":{"pattern":["lines","crosshatch"]}},
"pen_width_mm":{"type":"number","min":0.1,"max":5,"default":0.3,"label":"Pen width","unit":"mm","group":"Lines"},
"outline":{"type":"bool","default":true,"label":"Outline","group":"Outline"},
"outline_inset_mm":{"type":"number","min":-10,"max":10,"default":0,"label":"Inset","unit":"mm","group":"Outline",
"visibleWhen":{"outline":[true]}},
"outline_passes":{"type":"int","min":1,"max":4,"default":1,"label":"Passes","group":"Outline",
"visibleWhen":{"outline":[true]}}}}"#;
let src = tpl::module(manifest, tpl::ECHO, "", 1);
std::fs::write(dir.join("hatch-test-filter.wasm"), wat::parse_str(&src).unwrap()).unwrap();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(&src).unwrap())})).unwrap();
app.run("plugin.dialog", json!({"id": "org.example.hatch"})).unwrap();
let mut ready = false;
let mut h = egui_kittest::Harness::builder().with_size(vec2(720.0, 620.0)).wgpu().build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut ready, true) {
crate::theme::install_fonts(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark);
return;
}
show(app, ui.ctx());
},
app,
);
h.run_steps(6);
h.render().unwrap().save(dir.join("plugin-fields.png")).unwrap();
h.state_mut().run("plugin.remove", json!({"id": "org.example.hatch"})).unwrap();
}
@@ -159,6 +159,31 @@ fn key_frame(app: &mut VectorcraftApp, key: egui::Key) {
out.textures_delta.clear();
}
#[test]
fn rectangular_grid_reference_locator_reanchors_to_the_click() {
let mut app = app();
click(&mut app, Point::new(100.0, 100.0));
set(&mut app, "reference", json!(4));
super::shapes::grid_pin_to_anchor(app.ui.dialog.as_mut().unwrap());
assert_eq!((field(&app, "x"), field(&app, "y")), (json!(50.0), json!(50.0)));
let id = confirm(&mut app);
assert_grid(&app, id, Rect::new(50.0, 50.0, 150.0, 150.0), 5, 5);
}
/// #1377: OK uses the bounding box after a reference-point resize (`artboard_rect` keeps the
/// center pinned when width doubles).
#[test]
fn rectangular_grid_reference_point_pins_center_when_width_changes() {
let mut app = app();
click(&mut app, Point::new(100.0, 100.0));
assert_eq!(field(&app, "reference"), json!(0));
for (key, value) in [("reference", json!(4)), ("x", json!(50.0)), ("y", json!(50.0)), ("width", json!(200)), ("height", json!(100))] {
set(&mut app, key, value);
}
let id = confirm(&mut app);
assert_grid(&app, id, Rect::new(50.0, 50.0, 250.0, 150.0), 5, 5);
}
#[test]
fn rectangular_grid_canvas_click_opens_registered_dialog_with_saved_counts_and_point_defaults() {
let mut app = app();
@@ -52,7 +52,7 @@ fn assets(app: &mut VectorcraftApp) -> Vec<Value> {
fn export_selection_collects_the_selection_and_opens_the_assets_tab() {
let (mut app, log, _) = app(true);
assert!(crate::menus::enabled(&app, "file.exportSelection"));
let r = app.run("file.exportSelection", json!({})).unwrap();
let r = app.run("file.exportSelection", json!({"multiple": true})).unwrap();
let list = assets(&mut app);
assert_eq!(list.len(), 2, "one asset per object");
let ids: Vec<Value> = list.iter().map(|a| a["id"].clone()).collect();
@@ -61,7 +61,7 @@ fn export_selection_collects_the_selection_and_opens_the_assets_tab() {
assert_eq!((field(&app, "tab"), field(&app, "assets")), (json!("assets"), json!(ids)));
// Collecting the same art again opens on the same assets.
super::cancel(&mut app);
app.run("file.exportSelection", json!({})).unwrap();
app.run("file.exportSelection", json!({"multiple": true})).unwrap();
assert_eq!(assets(&mut app).len(), 2);
assert_eq!(field(&app, "assets"), json!(ids));
let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx()));
@@ -80,12 +80,15 @@ fn export_selection_collects_the_selection_and_opens_the_assets_tab() {
#[test]
fn export_selection_as_one_asset() {
let (mut app, _, ids) = app(true);
app.run("file.exportSelection", json!({"multiple": false})).unwrap();
let list = assets(&mut app);
assert_eq!(list.len(), 1);
assert_eq!(list[0]["nodes"], json!(ids));
assert_eq!(field(&app, "assets"), json!([list[0]["id"]]));
// By default the selection, ungrouped, becomes one asset of it all; `multiple: false` says so too.
for p in [json!({}), json!({"multiple": false})] {
let (mut app, _, ids) = app(true);
app.run("file.exportSelection", p.clone()).unwrap();
let list = assets(&mut app);
assert_eq!(list.len(), 1, "{p}");
assert_eq!(list[0]["nodes"], json!(ids), "{p}");
assert_eq!(field(&app, "assets"), json!([list[0]["id"]]), "{p}");
}
}
#[test]
@@ -142,3 +145,38 @@ fn the_menus_reach_asset_export() {
app.run("assets.add", json!({"multiple": false})).unwrap();
assert_eq!(assets(&mut app).len(), 1);
}
/// The Assets tab opened on new assets scrolls its list to the first checked one, once: they land
/// at the end of a long list.
#[test]
fn the_assets_tab_scrolls_to_the_new_asset() {
let (mut app, _, ids) = app(true);
for i in 0..30 {
let id = app.run("shape.rectangle", json!({"x": 5 * i, "y": 50, "width": 4, "height": 4})).unwrap()["id"].clone();
app.run("assets.add", json!({"ids": [id]})).unwrap();
}
app.run("select.set", json!({"ids": ids})).unwrap();
let r = app.run("file.exportSelection", json!({"multiple": false})).unwrap();
let new = r["assets"][0].clone();
assert_eq!(assets(&mut app).len(), 31);
assert_eq!(assets(&mut app).last().unwrap()["id"], new);
assert_eq!(field(&app, "__scrollTo"), new);
let name = assets(&mut app).last().unwrap()["name"].as_str().unwrap().to_string();
// The rows below the list's visible part are painted but clipped away: the new one is inside
// its clip once the frame after the opening one has scrolled.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut shown = false;
// Scrolling is animated: a second of frames lets it land.
for i in 0..60 {
let input = egui::RawInput { time: Some(f64::from(i) / 60.0), ..egui::RawInput::default() };
let mut out = ctx.run_ui(input, |ui| super::show(&mut app, ui.ctx()));
out.textures_delta.clear();
shown = out.shapes.iter().any(|c| match &c.shape {
egui::Shape::Text(t) => t.galley.text() == name && c.clip_rect.contains_rect(t.visual_bounding_rect()),
_ => false,
});
}
assert!(shown, "{name} is clipped out of the list");
assert_eq!(field(&app, "__scrollTo"), Value::Null, "scrolled once");
}
+11 -4
View File
@@ -3,6 +3,7 @@
use serde_json::{Value, json};
use super::shapes::grid_pin_to_anchor;
use crate::VectorcraftApp;
use crate::state::Dialog;
@@ -18,11 +19,17 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
"lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})),
// The grids: the tool's stored options (divider counts and skews, switches) at the default size.
"rectangularGrid" | "polarGrid" => {
let mut fields = app.session.tool_options_of(kind);
for (key, value) in [("x", json!(x)), ("y", json!(y)), ("width", json!("100 pt")), ("height", json!("100 pt"))] {
fields[key] = value;
let mut obj = match app.session.tool_options_of(kind) {
Value::Object(o) => o,
_ => serde_json::Map::new(),
};
for (key, value) in [("__anchorX", json!(x)), ("__anchorY", json!(y)), ("width", json!("100 pt")), ("height", json!("100 pt"))] {
obj.insert(key.into(), value);
}
Dialog::new(kind, fields)
obj.insert("reference".into(), json!(0));
let mut d = Dialog::new(kind, Value::Object(obj));
grid_pin_to_anchor(&mut d);
d
}
// Graph tool click: the graph's size.
"graph" => Dialog::new(
+5 -1
View File
@@ -467,7 +467,11 @@ pub(crate) fn panel_body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width:
ui.set_max_height(tall);
panels::layers::show(app, ui);
}
Some(DockTab::Libraries) => panels::libraries(app, ui),
Some(DockTab::Libraries) => {
// Its list scrolls inside `tall` (as in the dock) instead of growing the flyout.
ui.set_max_height(tall);
panels::libraries(app, ui);
}
None => panels::show_icon_panel(app, ui, id),
}
});
+41 -1
View File
@@ -1494,6 +1494,7 @@
Inspect Object Инспектиране на обекта
Install Plug-in Инсталиране на приставка
Install Plug-in… Инсталиране на приставка…
Open this setting's help page Отваряне на страницата с помощ за тази настройка
Installed plug-in {} Инсталирана приставка {}
Intensity: Интензивност:
Intent: Цел:
@@ -2112,6 +2113,7 @@
Output White Изходно бяло
Output › Mode Изход › Режим
Output: Изход:
Overlap Застъпване
Overlap: Застъпване:
Overlapping Застъпващи се
Overlay Прекриване
@@ -3092,7 +3094,7 @@
Delete Library Item Изтриване на елемент от библиотека
No results Няма резултати
No items in this library match “{query}”. Няма елементи в тази библиотека, съвпадащи с „{query}“.
Right-click an item to move it into this group. Щракнете с десния бутон върху елемент, за да го преместите в тази група.
Drag an item here, or right-click one, to put it in this group. Плъзнете елемент тук или щракнете с десния бутон върху него, за да го поставите в тази група.
New Group Нова група
New Group… Нова група…
Move to Group Преместване в група
@@ -3152,6 +3154,7 @@
Text Wrap Options Опции за обтичане на текст
Text Wrap Options… Опции за обтичане на текст…
The bounds of the visible art. Границите на видимия макет.
The plug-in writes the whole document. Приставката експортира целия документ.
The cell shows the type object's text as HTML. Клетката показва текста на текстовия обект като HTML.
The document has no colour swatches. Документът няма цветови образци.
The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Краят на хартията, до който принтерът не може да стигне: макетът навлиза в него, а прегледът го показва с пунктир.
@@ -5164,6 +5167,27 @@ theme Appearance Външен вид
Movie Clip Филмов клип
@msg name is empty името е празно
@msg no symbol named `{n}` няма символ с име `{n}`
Smudge Stick… Размазваща пръчка…
Smudge Stick Размазваща пръчка
Sponge… Гъба…
Sponge Гъба
Watercolor… Акварел…
Watercolor Акварел
Brush Size Размер на четката
Definition Дефиниция
Brush Detail Детайл на четката
Shadow Intensity Интензивност на сенките
Bas Relief… Барелеф…
Bas Relief Барелеф
Charcoal… Въглен…
Charcoal Въглен
Photocopy… Фотокопие…
Photocopy Фотокопие
Stamp… Печат…
Stamp Печат
Thickness Дебелина
Detail Детайл
Light/Dark Balance Баланс светло/тъмно
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one „Редактиране на оригинал“ отваря само файлове с изображения и документи; „{name}“ не е такъв файл
@msg only http, https and mailto URLs open in the browser, not “{url}” в браузъра се отварят само URL адреси с http, https и mailto, не „{url}“
@@ -5193,3 +5217,19 @@ theme Appearance Външен вид
Select by Color Избиране по цвят
Move Anchor Point Преместване на опорна точка
@msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} запазва 1 от {n} работни области на документа: файлът се отваря отново с един. Включете „Запазване на възможностите за редактиране“, за да запазите всички.
Chalk & Charcoal… Креда и въглен…
Chalk & Charcoal Креда и въглен
Graphic Pen… Графична писалка…
Graphic Pen Графична писалка
Reticulation… Ретикулация…
Reticulation Ретикулация
Plastic Wrap… Найлоново фолио…
Plastic Wrap Найлоново фолио
Chrome… Хром…
Chrome Хром
Drop here to take it out of its group Пуснете тук, за да го извадите от групата му
Move Library Group Преместване на библиотечна група
Plaster… Гипс…
Plaster Гипс
Note Paper… Хартия за писма…
Note Paper Хартия за писма
Can't render this file because it contains an unexpected character in line 2 and column 73.
+42 -1
View File
@@ -305,6 +305,7 @@
Plug-ins Zásuvné moduly
No filter plug-ins installed Nejsou nainstalovány žádné filtry
Install Plug-in… Nainstalovat zásuvný modul…
Open this setting's help page Otevřít stránku nápovědy k tomuto nastavení
Reload Plug-ins Znovu načíst zásuvné moduly
Type Text
Font Písmo
@@ -1657,7 +1658,7 @@ lighting Light Světlo
This library is empty Tato knihovna je prázdná
No results Žádné výsledky
No items in this library match “{query}”. Žádná položka v této knihovně neodpovídá „{query}“.
Right-click an item to move it into this group. Kliknutím pravým tlačítkem na položku ji přesunete do této skupiny.
Drag an item here, or right-click one, to put it in this group. Přetáhněte sem položku nebo na ni klikněte pravým tlačítkem a vložte ji do této skupiny.
New Group Nová skupina
New Group… Nová skupina…
Move to Group Přesunout do skupiny
@@ -1788,6 +1789,7 @@ lighting Light Světlo
Left in Front Levý vpředu
No document Žádný dokument
Open a document to edit its patterns. Otevřete dokument, abyste mohli upravit jeho vzorky.
Overlap Překrytí
Overlap: Překrytí:
Pattern options are available in pattern editing mode. Možnosti vzorku jsou dostupné v režimu úprav vzorku.
Right in Front Pravý vpředu
@@ -2167,6 +2169,7 @@ lighting Light Světlo
One file per artboard: <name>-<artboard>.{ext} Jeden soubor na pracovní plochu: <name>-<artboard>.{ext}
One page per artboard. Jedna stránka na pracovní plochu.
The bounds of the visible art. Hranice viditelné kresby.
The plug-in writes the whole document. Zásuvný modul exportuje celý dokument.
Use Artboards Použít pracovní plochy
Choose a folder Vyberte složku
Create Sub-folders Vytvořit podsložky
@@ -4542,6 +4545,7 @@ imageTrace Outline Obrys
@msg {_1} files are written by file.saveAsTemplate soubory {_1} zapisuje file.saveAsTemplate
@msg {_1} font families available Dostupné rodiny písem: {_1}
@msg {_1} is a live effect (Effect › Plug-ins) {_1} je živý efekt (Efekt › Zásuvné moduly)
@msg {_1} is an exporter (File › Export › Export As…) {_1} je exportní zásuvný modul (Soubor › Export › Exportovat jako…)
@msg {_1} is already used by {_2} {_1} už používá {_2}
@msg {_1} linked file(s) can't be read here: their previews show propojené soubory ({_1}) zde nelze přečíst: zobrazují se jejich náhledy
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions propojené obrázky ({_1}) se na disku změnily: nové verze zobrazí Aktualizovat propojení
@@ -5191,6 +5195,27 @@ mark-style Japanese Japonské
Movie Clip Filmový klip
@msg name is empty název je prázdný
@msg no symbol named `{n}` žádný symbol s názvem `{n}`
Smudge Stick… Rozmazávací tyčinka…
Smudge Stick Rozmazávací tyčinka
Sponge… Houba…
Sponge Houba
Watercolor… Akvarel…
Watercolor Akvarel
Brush Size Velikost štětce
Definition Definice
Brush Detail Detail štětce
Shadow Intensity Intenzita stínů
Bas Relief… Basreliéf…
Bas Relief Basreliéf
Charcoal… Uhel…
Charcoal Uhel
Photocopy… Fotokopie…
Photocopy Fotokopie
Stamp… Razítko…
Stamp Razítko
Thickness Tloušťka
Detail Detail
Light/Dark Balance Vyvážení světlé/tmavé
# Links and Attributes: files and URLs handed to other apps.
@msg Edit Original opens image and document files only; “{name}” is not one Upravit originál otevírá pouze soubory obrázků a dokumentů; „{name}“ mezi ně nepatří
@msg only http, https and mailto URLs open in the browser, not “{url}” v prohlížeči se otevírají pouze adresy URL http, https a mailto, ne „{url}“
@@ -5232,3 +5257,19 @@ mark-style Japanese Japonské
Select by Color Vybrat podle barvy
Move Anchor Point Přesunout kotevní bod
@msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} zachová 1 z {n} pracovních ploch dokumentu: soubor se znovu otevře s jedním. Zapněte Zachovat možnosti úprav, aby zůstala všechna.
Chalk & Charcoal… Křída a uhel…
Chalk & Charcoal Křída a uhel
Graphic Pen… Grafické pero…
Graphic Pen Grafické pero
Reticulation… Síťování…
Reticulation Síťování
Plastic Wrap… Plastová fólie…
Plastic Wrap Plastová fólie
Chrome… Chrom…
Chrome Chrom
Drop here to take it out of its group Pusťte sem, chcete-li ji vyjmout ze skupiny
Move Library Group Přesunout skupinu knihovny
Plaster… Sádra…
Plaster Sádra
Note Paper… Dopisní papír…
Note Paper Dopisní papír
Can't render this file because it contains an unexpected character in line 2 and column 73.
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more