Compare commits

...
Author SHA1 Message Date
bflatastic 87a5b4cc83 Docs: the ROADMAP records the community PRs landed on 2026-10-18 2026-10-18 01:57:27 +07:00
bflatastic d7a61f1c50 Docs: one M9 roadmap row and one Optical Margin Alignment row after the batch 2026-10-18 01:57:27 +07:00
BFlat 95e98d18de Merge pull request #2684 from dotCooCoo/fix/export-for-web-warns-undecodable-image
Save for Web: saving and Preview in Browser warn when an image couldn't be decoded
2026-10-18 01:57:12 +07:00
BFlat f4668bfb2b Merge pull request #2726 from dotCooCoo/fix/style-names-trimmed-on-create
Character and Paragraph Styles: a new style's name is trimmed as a rename trims it
2026-10-18 01:56:57 +07:00
BFlat 590f91574d Merge pull request #2742 from dotCooCoo/fix/tabs-panel-menu-items-enabled
Tabs: Clear All Tabs, Delete Tab and Repeat Tab can be chosen from the panel menu
2026-10-18 01:56:42 +07:00
BFlat facab88bcc Merge pull request #2746 from dotCooCoo/fix/brush-pattern-artboard-names-trimmed
Brushes, patterns and artboards: new and renamed items have their names trimmed
2026-10-18 01:56:27 +07:00
BFlat 4ffaeb1895 Merge pull request #2774 from dotCooCoo/fix/edit-undo-redo-step-names-translated
i18n: Edit › Undo and Redo name the step as the History panel does
2026-10-18 01:56:13 +07:00
BFlat 99b3917aaa Merge pull request #2715 from dotCooCoo/fix/issue-2054
Fix #2054: bounding-box resize handles snap to the grid, pixels, points and ruler guides
2026-10-18 01:56:02 +07:00
BFlat 2ef544cab7 Merge pull request #2717 from dotCooCoo/fix/linux-tablet-xwayland
Linux Display Server: XWayland when a pen is attached, where a drawing tablet's pen draws
2026-10-18 01:55:46 +07:00
BFlat 67673faea7 Merge pull request #2729 from dotCooCoo/fix/canvas-test-worker-deadlines
Tests: five tests that wait on another thread pass on a loaded machine
2026-10-18 01:55:29 +07:00
BFlat ec3a1d2a82 Merge pull request #2736 from dotCooCoo/feat/dataset-import-header-prefixes
Variables: Import Data Sets… defines the variables a file's header names
2026-10-18 01:55:15 +07:00
BFlat a96a6800c9 Merge pull request #2762 from dotCooCoo/fix/exports-keep-encoder-warnings
Export: Save for Office, CSS and Copy warn when an image couldn't be decoded or a font is missing
2026-10-18 01:55:00 +07:00
BFlat 2ca923b43a Merge pull request #2737 from dotCooCoo/fix/export-for-screens-keeps-warnings
Export for Screens: exports warn when an image couldn't be decoded or a font is missing
2026-10-18 01:54:45 +07:00
BFlat ef666b4bfd Merge pull request #2738 from dotCooCoo/fix/file-names-windows-characters
Export: names typed into file names follow Windows' naming rules
2026-10-18 01:54:31 +07:00
BFlat e26393c7d8 Merge pull request #2740 from dotCooCoo/fix/clippy-1-95-artistic-filters
Effects: vectorcraft-effects passes clippy on Rust 1.95, the minimum toolchain
2026-10-18 01:54:16 +07:00
BFlat ba69d744ae Merge pull request #2745 from dotCooCoo/fix/issue-2558-drag-preview
Part of #2558: a drag draws zoomed-in raster effects at a lower resolution until it ends
2026-10-18 01:54:01 +07:00
BFlat 879fb63248 Merge pull request #2747 from dotCooCoo/fix/batch-extension-from-format
Actions: Batch's Override Save and Export name files with the format's extension
2026-10-18 01:53:47 +07:00
BFlat 5b7411e9d1 Merge pull request #2760 from dotCooCoo/fix/ai-import-difference-note-counts-art
.ai import: the warning that the layers differ from the page gives the art's share or names the type
2026-10-18 01:53:32 +07:00
BFlat b5715f7509 Merge pull request #2761 from dotCooCoo/feat/artboards-select-all
Artboards: Ctrl/Cmd+A in the Artboards panel selects every artboard with the Artboard tool
2026-10-18 01:53:16 +07:00
BFlat 66cfc1dca6 Merge pull request #2764 from dotCooCoo/feat/history-panel-step-backward-forward
History: the panel menu has Step Backward, Step Forward and Set History Limit…
2026-10-18 01:53:00 +07:00
BFlat 74cf04fa3b Merge pull request #2767 from dotCooCoo/fix/preset-rename-writes-before-removing
PDF presets: a rename that can't be written keeps the preset under its old name
2026-10-18 01:52:45 +07:00
BFlat fda352cf7b Merge pull request #2766 from dotCooCoo/feat/place-docx
Place: Word .docx files place as area type with their formatting
2026-10-18 01:52:31 +07:00
BFlat 98fc8146dc Merge pull request #2770 from dotCooCoo/feat/tiff-ccitt-g3
TIFF: CCITT Group 3 compressed files open and place
2026-10-18 01:52:16 +07:00
BFlat 3988aafc1a Merge pull request #2772 from dotCooCoo/fix/marquee-skips-shaper-sources
Shaper: a marquee does not select a Shaper Group by the original shape of an erased face
2026-10-18 01:52:01 +07:00
BFlat 2433c44915 Merge pull request #2773 from dotCooCoo/feat/optical-margin-alignment-follows-thread
Type: Optical Margin Alignment is a setting of the whole thread
2026-10-18 01:51:45 +07:00
BFlat dfde775213 Merge pull request #2757 from dotCooCoo/fix/saved-text-sizes-in-range
Type: sizes, scales, leading, indents and spacing out of range in a file are read in range
2026-10-18 01:51:28 +07:00
BFlat 2fa0e979f3 Merge pull request #2728 from dotCooCoo/fix/saved-script-sizes-in-range
Type: superscript, subscript and small caps proportions out of range in a file are read in range
2026-10-18 01:51:15 +07:00
BFlat eba745e354 Merge pull request #2471 from VL420/feat/task-bar-more
G1: Contextual Task Bar: Join, Edit Path, Release and type conversion by selection
2026-10-18 01:50:59 +07:00
BFlat 64fda576b2 Merge pull request #2787 from storytold/pr/2706-colored-lights
3D effects: one to ten coloured lights (#2706)
2026-10-18 01:50:40 +07:00
BFlat 07bf1ddf8c Merge pull request #2395 from EricKuck/fix/ai-outer-glow-solid-silhouette
Shadows and glows: each is of the art with the ones before it in the stack, and an Outer Glow blurs a solid silhouette
2026-10-18 01:50:04 +07:00
BFlat cc5fba7577 Merge pull request #2643 from makesomethingshit/fix/2544-blend-spine-anchor-tool
Blend: edit spine points and handles with the Anchor Point tool (#2544)
2026-10-18 01:49:51 +07:00
BFlat 95427b71bf Merge pull request #2720 from MarekSpodBiedry/issue-2655
Magic Wand: only select objects inside the isolated group (#2655)
2026-10-18 01:49:34 +07:00
BFlat c9bebb92d6 Merge pull request #2721 from MarekSpodBiedry/issue-2673
Map the WMF window onto the placeable box (#2673)
2026-10-18 01:49:20 +07:00
BFlat eaa21d42d5 Merge pull request #2722 from MarekSpodBiedry/issue-2693
Write legacy .ai layer names in Windows Latin-1 and warn when a character is replaced (#2693)
2026-10-18 01:49:06 +07:00
BFlat d5cfb27989 Merge pull request #2730 from nvict-service/i18n-nl-complete
i18n(nl): complete the Dutch catalog
2026-10-18 01:48:56 +07:00
BFlat a7fded4584 Merge pull request #2732 from vangel76/fix/2491-fallback-glyph-style
Fonts: fallback glyphs keep bold/italic, Bold Italic of a missing family keeps the bold, CJK prefers an installed sans (#2491)
2026-10-18 01:48:39 +07:00
BFlat 3e09a469de Merge pull request #2733 from vangel76/fix/2521-variable-default-face
Fonts: a variable font's default face is named and weighed by its default instance (#2521)
2026-10-18 01:48:25 +07:00
BFlat 079cd4983e Merge pull request #2739 from guillehr2/fix/1876-artboard-options-xy
Fix #1876: Artboard Options has X and Y with a reference point
2026-10-18 01:48:09 +07:00
BFlat 1651e7bea7 Merge pull request #2775 from VL420/feat/roman-hanging
G4: Paragraph panel menu › Roman Hanging Punctuation
2026-10-18 01:47:57 +07:00
bflatastic e01fe134e9 Save for Web: the undecoded-image warning comes from the shared helper 2026-10-18 01:32:01 +07:00
bflatastic f5d61f6f27 Styles: a blank new style name says to give the style a name 2026-10-18 01:32:01 +07:00
bflatastic 7da58e35cc Tabs: Clear All Tabs reports a failure in the status bar 2026-10-18 01:32:01 +07:00
dotCooCoo a03583fc73 Save for Web: saving and Preview in Browser warn when an image couldn't be decoded
document.exportForWeb renders the artboard or the art and writes GIF,
JPEG, PNG-8 or PNG-24 files. When the art held an image that the
renderer could not decode, the renderer drew nothing for it, and the
files lacked the image with no message: the reply had no warnings at
all. A raster export of the same art warns that "an image couldn't be
decoded and is missing from the picture".

document.exportForWeb and document.exportForWeb.preview now compare
decode_failures before and after they render, as raster exports do,
and their replies carry that text in `warnings`. The files are still
written. Save, Download and Preview in Browser in the Save for Web
dialog show the first warning in the status bar as Save As shows a
save's notes.

Docs: docs/mcp.md (Save for Web), docs/control-protocol.md.
2026-10-18 01:32:01 +07:00
dotCooCoo 598f1de3cc Character and Paragraph Styles: a new style's name is trimmed as a rename trims it
charStyle.new and paraStyle.new kept the name they were given as it
was, while charStyle.rename and paraStyle.rename trim the white space
around the new name. So a script could make a style " Body" beside
"Body", and a blank name was refused with the message for a name that
is taken.

New Character Style and New Paragraph Style now trim the name as rename
does, compare the trimmed name with the styles there are, and refuse a
name that is then empty with rename's message. Duplicate names the copy
from the trimmed name. symbol.new and symbol.duplicate trim the name as
symbol.options trims newName, and a blank newName gives "<name> copy"
instead of a symbol named with the blank. library.use adds a library's
text style under its trimmed name, so a library file whose style is
named with spaces around it still applies.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:32:01 +07:00
dotCooCoo 3fe8aa7db9 Brushes, patterns and artboards: new and renamed items have their names trimmed
brush.new, brush.duplicate, object.pattern.make,
object.pattern.saveCopy, artboard.new and artboard.setProps kept the
name they were given as it was, and brush.options and pattern.options
kept the white space around a new name. So a script could make a brush
" Nib" beside "Nib", and a blank name made a brush, a pattern or an
artboard named with the blank. Artboard Options sends its Name field
as typed, so clearing it named the artboard "". The Brush Options
dialog, the Pattern Options panel and the Artboards panel trim the
names they send.

The eight commands now trim the name, and the brush and pattern
commands compare the trimmed name with the names there are. In
brush.new, brush.duplicate, object.pattern.make,
object.pattern.saveCopy and artboard.new, a blank name gives the name
the command gives when there is none: "New Calligraphic Brush" and the
other default brush names, "<name> copy", "New Pattern N",
"<pattern> copy" and "Artboard N". brush.options and pattern.options
keep the name when the new one is blank, and artboard.setProps gives a
blank one the artboard's default name, Artboard N for the Nth
artboard. Duplicate Brush and Save a Copy name the copy of a brush or
pattern whose saved name has white space around it from the trimmed
name (" Old " gives "Old copy").

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
bflatastic 015bab68a1 Undo/Redo: the menu names the step with the History panel's own function 2026-10-18 01:27:14 +07:00
dotCooCoo 23dd9a1f2a Fix #2054: bounding-box resize handles snap to the grid, pixels, points and ruler guides
A bounding-box handle dragged with the Selection or Free Transform tool
snapped only with Smart Guides on, and then only to the other objects
and the artboards. View > Snap to Grid, Snap to Pixel, Snap to Point
with Smart Guides off and the ruler guides did not move it, while a
move snaps to all of them.

The corner or side a handle moves now snaps as a dragged point does.
With Snap to Grid on it lines up with the nearest gridline, and with
Snap to Pixel on it lines up with whole pixels. With Smart Guides off,
Snap to Point lands it on the other objects' anchors and on ruler
guides within its distance. With Smart Guides and Snap to Point both
on, it lines up with the ruler guides as well as with the other
objects. Shift still keeps the proportions. Without Alt the opposite
side stays put, and with Alt the box still scales about its center.

Docs: docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 20590e778c Linux Display Server: XWayland when a pen is attached, where a drawing tablet's pen draws
On a Wayland session a drawing tablet's pen could not draw or click:
winit 0.30 does not bind the Wayland tablet protocol, and neither KWin
(since Plasma 6.3) nor GNOME's Mutter turns tablet input into pointer
input. Linux Display Server's X11 (XWayland) choice let the pen work as
a mouse, but it opened the window through XWayland on every start, also
without a tablet.

Linux Display Server has a third choice, X11 (XWayland) When a Pen Is
Attached (x11WithPen). It opens the window through XWayland on a
Wayland session only when /proc/bus/input/devices lists a pen as the
app starts: a device with the BTN_TOOL_PEN key and the ABS_X and ABS_Y
axes, the check PhotoCraft's tablet.rs makes. Automatic stays the
default. On Linux the Performance page shows a note for tablet pens
that don't draw.

README.md named KDE alone; the KWin commit's message says Mutter does
not emulate tablet events either, so README.md and docs/development.md
now name GNOME. The Pen pressure row of docs/hardware-parity.md said
Linux has pressure through XWayland; winit 0.30 reports none on X11
either.

Docs: README.md, docs/development.md, docs/hardware-parity.md,
docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 04e784a04f Tests: five tests that wait on another thread pass on a loaded machine
On a machine busy with other builds, five tests on main failed now and
then and passed when run alone. The canvas test for dragging an object
with raster effects gave the render worker 10 s for its frame, which
took 0.10 s alone and 5.8 to 9.4 s with four ui-egui test runs at
once. The three tests_sysclip_probe tests gave the clipboard probe's
thread 2 or 3 s. The control server's persistent-requests test sent
each request with writeln!, which sends the JSON to the socket in many
small writes, and a pause of more than the test's 120 ms line deadline
between two of them closed the connection.

The canvas test now waits up to 60 s for the worker, as another test in
canvas.rs does. The probe tests wait up to 60 s too. The control server
test sends each request line in one write and keeps its 120 ms line
deadline. Only test code changes.
2026-10-18 01:27:14 +07:00
dotCooCoo 1de20a2972 Tabs: Clear All Tabs, Delete Tab and Repeat Tab can be chosen from the panel menu
The Tabs panel menu passed false as each item's enabled argument and
its condition as the checked argument, so Clear All Tabs, Delete Tab
and Repeat Tab were always dimmed and, with a stop selected, showed a
check mark.

The three items are now enabled while the ruler shows tab stops and
show no check mark. Delete Tab and Repeat Tab act on the stop the ruler
shows selected. After Delete Tab removes the last stop, the ruler shows
the stop before it selected, and the next Delete Tab removes that one.
Clear All Tabs runs text.tabs.clear on the paragraphs the panel acts
on, so its undo step is named Clear All Tabs.

Docs: docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 35d70f5531 i18n: Edit › Undo and Redo name the step as the History panel does
Edit › Undo and Edit › Redo translated the step's name with its plain
catalog row only. Nine of the 22 catalogs have no plain row for
Feather, only the effect's menu row with an ellipsis, so in Japanese
Edit › Undo read "Featherを取り消す" after Feather while the History
panel listed the step as ぼかし.

The menu now names the step with panels::history::step_name, as the
History panel does, and keeps the name as recorded when the catalog
has no row for it. The in-window and native menus both show the new
label.

Docs: docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 2ccb147403 Variables: Import Data Sets… defines the variables a file's header names
dataset.import refused every header cell that wasn't the name of a
variable the document defines. A data source file could only fill
variables made beforehand, and `#` and `@` in a header were read as part
of a variable's name.

A header cell that names no variable now defines a new, unbound
variable in the import's undo step. `#Shown` defines a visibility
variable `Shown`, `@Photo` defines an image variable `Photo`, and a
cell without a prefix defines a text variable. A prefixed cell whose
name is already a variable of the prefix's kind names it. A cell that
is a variable's exact name keeps naming it, even when the name starts
with `#`, `@` or `%`, so files that dataset.export writes import back
as before. The import fails, naming the column, on a prefixed cell
whose name is a variable of another kind and on a `%` cell that names
no variable (graph data files). While the variables are locked, a
header that would define a variable fails the import with "variables
are locked", as New Variable does. The reply's `defined` lists the new
variables.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:27:13 +07:00
dotCooCoo a12f5b0f20 Export: Save for Office, CSS and Copy warn when an image couldn't be decoded or a font is missing
Save for Office Documents, the pictures that css.export rasterizes and
the PNG and PDF that Copy offers other apps were encoded with
fileio::encode, which drops the encoder's warnings. When the art held
an image that the renderer could not decode, the PNGs lacked it; type
in a font that is not installed came out in the fallback font in the
Office PNG and Copy's PNG and PDF. No reply or status message said so.
The app also dropped the warnings that document.exportSelection
returns.

document.exportForOffice, clipboard.exportPng and clipboard.exportPdf
now return `warnings`, and css.export adds the notes of its pictures to
its `warnings`, each once. Save for Office Documents and
document.exportSelection show the first note in the status bar in Save
As's message, and the CSS Properties panel's Export Selected CSS… and
Export All… show it in Export As's message. After Copy or Cut, once
the PNG and PDF are on the system clipboard, the status bar shows their
first note. The files are written as before.

Docs: docs/mcp.md (Clipboard, CSS Properties), docs/control-protocol.md.
2026-10-18 01:27:13 +07:00
bflatastic efb49169a3 Export for Screens: rustfmt the import list (#2737) 2026-10-18 01:27:13 +07:00
dotCooCoo 6b13dd776b Export: names typed into file names follow Windows' naming rules
Export for Screens, Batch over data sets, Save for Web and the
Libraries panel name files after text the person typed: a prefix, a
suffix, a name pattern, the name of an artboard, asset, layer, slice,
data set or library, or the document's title. The prefix, the suffix,
name patterns and Batch's data set names had only /, \ and : replaced,
so on Windows a name holding <, >, ", |, ?, * or a control character
failed to save, and Save for Web without a path named its files after
the document's title as it was. A row folder name could end in a
period, which Windows does not allow, and names such as CON, NUL, COM1
or LPT1, which Windows reserves for devices, were written as they
were.

file_part, which Export for Screens' prefix and suffix and Batch's
data set names use, now writes all of those characters as -, and name
patterns use it in place of their own copy. A new safe_file_name runs
a whole file or folder name through file_part, removes trailing
periods and spaces, and adds - after a device name that is the whole
name or comes before its first period (CON.png becomes CON-.png).
Export for Screens uses it for each file, folder and zip it names, and
keeps item names distinct after it. Save for Web uses it for slice
images and, without a path, for files named after the title. Library
files use it too. This happens on every platform. Other names are
written as before, except where a changed name now equals another
item's name, which then gets a number added.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:27:13 +07:00
dotCooCoo 26af00d108 Effects: vectorcraft-effects passes clippy on Rust 1.95, the minimum toolchain
With Rust 1.95, the workspace's rust-version, cargo clippy --workspace
--all-targets -- -D warnings stopped at vectorcraft-effects: clippy 1.95
reports nonminimal_bool on the parameter and pixel-size guards of
Cutout, Poster Edges and Neon Glow, and on the size check in
Rasterize::compose (Effect › Rasterize). Clippy 1.99 does not report
them.

Each check now has the form clippy suggests, one !(a && b && ...) in
place of !a || !(b && c) (!a || !b || !(c && d) in compose). The
operands and their order are unchanged, so the filters and compose
return early in the same cases as before.
New tests check that the three filters leave the art unchanged for a
pixel size of 0, -1, NaN or infinity and for non-finite parameters,
and that compose leaves the raster unchanged when a buffer's length
does not match its size or the image's area has no positive width or
height.
2026-10-18 01:27:13 +07:00
bflatastic 6e29afe648 Render: the drag budget for shadows returns None, as the cached-shadow path does since #2395 (#2745) 2026-10-18 01:27:13 +07:00
dotCooCoo 919970a467 Export for Screens: exports warn when an image couldn't be decoded or a font is missing
Export for Screens wrote each file with fileio::encode, which drops the
encoder's warnings, and its replies had no `warnings` key. When the art
held an image that the renderer could not decode, or type in a font
that is not installed, the files lacked the image or used the fallback
font, and neither the reply nor the status bar said so. document.export
returns the same notes in `warnings`, and Export As shows the first in
the status bar.

document.exportForScreens and assets.export now collect the warnings of
every file they export, each once, and every reply carries them in
`warnings`. The export still writes or returns every file. When there
are warnings, the Export for Screens dialog and the Asset Export panel
show the first in the status bar as Save As shows a save's notes.

Docs: docs/mcp.md (Export for Screens), docs/control-protocol.md.
2026-10-18 01:27:13 +07:00
dotCooCoo 7f858a7e59 Actions: Batch's Override Save and Export name files with the format's extension
With Override Save or Override Export on, action.batch named each
step's file in the destination folder with the step's format exactly as
written. The save and export commands read the format in any case and
with a leading dot, so a step with "format": ".svg" wrote
"Untitled_a..svg", "SVG" wrote ".SVG", "png8" wrote ".png8" and
"template" wrote ".template".

The file now ends in the format's extension: the extension as written,
in lower case and without the dot, when it is one of the format's
(".JPEG" gives ".jpeg"), and otherwise the format's first extension, the
one Export and Save use for a file name ("png8" gives ".png", "template"
gives ".vctemplate"). An unknown format ("xyz") keeps its spelling, and
the step fails with the command's error as before.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo dc48ce6c74 .ai import: the warning that the layers differ from the page gives the art's share or names the type
When a .ai or EPS opens from its editing data and its layers draw a
little differently from the file's own page, the warning gave the share
of all the pixels that differ, type included, and blamed brush strokes
and live effects. Type that the import lays out again from the file's
text, or draws in another font, raised it on its own: a line that the
page sets in pieces gave "about 1.5% of it (brush strokes, live effects
and the like ...)" with no art differing.

The comparison now also counts the pixels that differ away from type.
The warning gives that share, and when the art away from type does not
differ by more than the warning's limit, the warning names the type
instead. The warning appears in the same cases as before, and the
limits that make the file come in as its page are unchanged.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo ceddd8185f Artboards: Ctrl/Cmd+A in the Artboards panel selects every artboard with the Artboard tool
With the Artboard tool in use, Ctrl/Cmd+A after a click on a row of the
Artboards panel selected all the art. The panel now takes the keyboard
when one of its rows is clicked, as the Layers panel does for Delete,
and gives it up when the pointer is pressed outside its list or the
panel is not drawn.
While it has the keyboard and the Artboard tool is in use, Select All's
keys, and Select All in the macOS menu bar, select every artboard: the
active artboard stays active and the art's selection does not change.
Otherwise Select All still selects the art.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo aeb9314e2c Part of #2558: a drag draws zoomed-in raster effects at a lower resolution until it ends
When zoomed in, an object with a texture, a blur or a shadow was slow
to drag. Filtered content whose on-screen area passes the 8.4 million
pixel raster budget keeps no raster, and a shadow or glow larger than
4 million pixels keeps none either, so every frame of the drag ran the
effects again over up to 8.4 million pixels. A drag that scales or
rotates an object runs them again at any zoom.

The canvas now renders a tool's drag with RenderOptions::dragging. In
such a frame the renderer draws filtered content within 1,000,000
pixels, at a lower resolution where it would need more, unless it can
draw the content from an earlier frame's raster. On the multithreaded
pipeline it does the same for drop shadows, outer glows and inner
glows. No raster made that way is kept. They count as moved rasters,
so a raster made over them is made again after the drag, and the
desktop canvas renders the frame after the drag on the render worker.
That frame renders them as a still frame does. A dialog's preview,
exports and printing render as before.

zoomed_drag_perf times a drag's frames at 100% to 6400% with and
without dragging.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo 85fce6852e History: the panel menu has Step Backward, Step Forward and Set History Limit…
The History panel menu held only a disabled History Options… item. The
reference app's History panel menu has Step Backward and Step Forward,
which undo and redo a change, and Set History Limit, which sets the
number of History States.

The panel menu now has Step Backward and Step Forward. They run
edit.undo and edit.redo, and each is disabled when there is no step to
undo or redo, as Edit › Undo and Edit › Redo are. Set History Limit…
opens Preferences at Performance, where History States is set.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:34 +07:00
dotCooCoo 6a2bb03ebf PDF presets: a rename that can't be written keeps the preset under its old name
Renaming a saved PDF preset (pdf.preset.save with newName) removed
the old file from the PDF Presets folder before it wrote the new one.
When that write failed, for example on a full disk, the folder held no
file for the preset, and the preset was gone at the next start.

The store now writes the new file first and removes the old one only
when that write succeeded. A failed write leaves the preset in the
folder under its old name. A rename that changes only the case of the
name writes the old file and then renames it, and the old file gets
its content back when the rename fails. The command still succeeds
when the folder can't be written, and the failure is logged, as
before.
2026-10-18 01:24:34 +07:00
bflatastic a209996da2 Docs: file-format-parity counts Word .docx among the formats placed (#2766) 2026-10-18 01:24:34 +07:00
dotCooCoo 1c4ead3703 Place: Word .docx files place as area type with their formatting
File > Place could not place a Word document: a .docx file was "not a
format VectorCraft reads". The reference app's File > Place reads
Microsoft Word files as text.

File > Place now reads .docx files, written from ECMA-376 Part 1
(WordprocessingML, Transitional and Strict), and sets their text as
area type the way a text or RTF file is placed. The package is read
through its relationships. The formatting comes from the document
defaults, the paragraph style (else the default paragraph style), the
character style (else the default character style), the styles each
is based on, and direct formatting. Bold, italic, caps, small caps,
strikethrough and hidden text are toggle properties: a style that
turns one on reverses the state the styles before it give.

Each run keeps its font (theme fonts from the theme part), size, bold
and italic, underline, strikethrough, all caps, small caps,
superscript or subscript (in Document Setup's proportions), raised or
lowered text and color. Each paragraph keeps its alignment, start,
end, first-line and hanging indents, space before and after, and
direction; the start indent of a right-to-left paragraph is its right
indent. Table cells are separated by tabs and each table row is a
paragraph. Text in insertions, hyperlinks, content controls and
fields' results is read. Hidden and deleted text, field instructions,
pictures, text boxes, headers, footers, notes and list numbers are
left out.

A ZIP archive named .docx, or one that holds word/document.xml, is
read as a Word document. fileio/zip.rs gains a reader for stored and
deflated files that checks each file's CRC. A package with more than
10,000 files, a part that unpacks to more than 16 MiB or nests
elements more than 256 deep, and a document with more than 4 MiB of
text or 100,000 paragraphs or runs are refused with an error. The
Place dialog lists Word files, and file.place, file.place.info and
file.place.queue report the format `docx`. .doc files are still not
read.

The Word document tests in tests_textimport.rs fail without the
change, and text_import_fuzz.rs places random and damaged packages.

Docs: docs/file-format-parity.md, docs/mcp.md,
docs/target-app-parity.md.
2026-10-18 01:21:31 +07:00
dotCooCoo dd466d79e4 TIFF: CCITT Group 3 compressed files open and place
1-bit TIFFs compressed with CCITT Group 3 did not open, place or
relink: the image decoder reads Group 4 only and refuses Compression 2
(modified Huffman) and Compression 3 (ITU-T T.4).

They are now decoded with hayro-ccitt, the CCITT decoder that EPS
import uses. Compression 2 rows start at a byte and have no EOL codes.
Compression 3 rows follow an EOL code and are one-dimensional, or, with
T4Options bit 0, one- or two-dimensional as each row's tag bit says.
Fill bits, FillOrder 2, either PhotometricInterpretation, strips and
tiles are read.

A file whose pixels would take more than the decoding allowance as
RGBA is refused as too large to draw. A strip or tile whose data ends
before its last row is refused as damaged, and so is a tile larger
than the allowance. The strips and tiles together are read from at
most as many bytes as the file holds.

Part of #1466.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-18 01:21:30 +07:00
dotCooCoo 8fbd515d6c Shaper: a marquee does not select a Shaper Group by the original shape of an erased face
A Shaper Group keeps its original shapes as a transparent first child.
Clicks skip them outside Construction mode, but a marquee did not: one
drawn over the outline of an original shape whose face the group had
erased selected the group, where nothing is shown. Group Selection's E
marquee selected an original shape that lay inside it, and Direct
Selection's marquee and clicks selected the anchor points of the
original shapes.

The marquee now skips a Shaper Group's original shapes with the same
check clicks use, which moves into shaper_hides, and Direct Selection's
walk over the art it can pick skips them too. In Construction mode, with
the original shapes isolated, the marquee still selects them and Direct
Selection still selects their anchor points.

Docs: docs/shaper.md, docs/target-app-parity.md.
2026-10-18 01:21:28 +07:00
dotCooCoo c44fb242aa Type: Optical Margin Alignment is a setting of the whole thread
Optical Margin Alignment was stored and set per text object, so the
frames of one thread could differ. Turning it on in the first frame of
a thread left the lines in the other frames flush with their margins.

Type › Optical Margin Alignment and type.opticalMarginAlignment now set
every frame of a selected frame's thread, in one undo step, and the
reply's ids list them all. After any edit to a thread, its frames take
the setting of the frame that the edit turned on or off, else the
first frame's. Threading frames gives the thread its first frame's
setting, and frames taken out of a thread keep it. A file whose frames
of one thread differ opens with the first frame's setting in all of
them. This holds for native files and for documents restored from the
editing copy that an EPS, SVG, PDF or .ai file carries.

Threading text from an out port into existing area type
(text.thread.add with to) now flows the story into that frame at once.
Before, the story reached it only at the next edit of one of the
thread's frames.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
2026-10-18 01:21:27 +07:00
dotCooCoo e5945c6795 Type: sizes, scales, leading, indents and spacing out of range in a file are read in range
A .vectorcraft file's type opened with the character and paragraph
values it held. With a size of 0, the characters were not drawn. With
a size, leading, horizontal scale, left indent or space before of
1.797e308, point type's bounds were infinite or over 1e308 wide or
high, and moving or scaling it failed with "result would exceed the
canvas".

These values are now read into range when a file opens, in the type of
the layers, symbols, graph designs, patterns and the pattern definition
that Cancel puts back in pattern editing mode, and in character and
paragraph styles. A size, horizontal scale or vertical scale of 0 or
less becomes 12 pt or 100%, and a negative leading becomes Auto. A
size, leading or scale past 4,000,000 (MAX_COORD) becomes 4,000,000,
and a baseline shift, indent or space before or after past 4,000,000
either way becomes 4,000,000 or -4,000,000. A tab stop is kept to the
0 to 100,000 pt that text.tabs.set takes. A leading of 0, which legacy
.ai import stores, and the sizes, scales and indents within these
limits that SVG, PDF and .ai import keep outside the panels' ranges
open as saved.

Docs: docs/mcp.md (Paragraph attributes per paragraph).
2026-10-18 01:21:25 +07:00
dotCooCoo 81574b1648 Type: superscript, subscript and small caps proportions out of range in a file are read in range
document.setup accepts a superscript and subscript size of 1 to 500%
and a position of -500 to 500%, and a small caps size of 1 to 200%.
text.setFormat copies these proportions into the characters it
formats. A .vectorcraft file's characters, character styles and graph
label styles opened with the proportions they held. With a superscript
size of 0, the superscript characters were not drawn, and with a
superscript or small caps size of 1.797e308, moving the type failed
with "result would exceed the canvas".

These proportions are now read as the nearer end of each range when a
file opens: in the type of the layers, symbols, graph designs, patterns
and the pattern definition that Cancel puts back in pattern editing
mode, in graph label styles and in character styles. Proportions in
range and the other character attributes open as saved.

Docs: docs/mcp.md (Small Caps, Superscript and Subscript).
2026-10-18 01:21:24 +07:00
VictorandClaude Opus 5.5 f7382b2844 G1: Contextual Task Bar: Join, Edit Path, Release and type conversion by selection
The task bar offers more next steps for each kind of selection:
- an open path: Join (closes it) and Edit Path (the Direct Selection
  tool); a closed path: Edit Path;
- a compound path: Edit Path and Release Compound Path;
- a clipping group: Edit Clipping Path and Release Clipping Mask
  instead of Ungroup and Isolate;
- an image selected with vector art: Make Clipping Mask instead of Unite;
- point or area type: Convert To Area Type / Convert To Point Type
  beside Create Outlines.

Buttons may now carry command parameters. "Edit Path" is translated in
every catalog; the other labels reuse the menu's. cargo xtask ci passes
all 9 steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:11:39 +07:00
alpha-rock 039a467dcb M9: add multiple colored lights to 3D effects
Add opaque RGB light color and up to ten independently adjustable lights to
Revolve, Extrude & Bevel, Inflate and Rotate. Keep light selection, Add/Delete,
selected-light gizmos and preview transactions on the existing command path.

Preserve legacy white-light Inflate polygons, paint composition and seam joins,
including the 0.05 seam minimum. Use shared smooth normals for colored Inflate,
and batch solid-fill RGB channel paths behind one surface clip. Keep source
paths editable and SVG/PDF output vector artwork.

Rebase onto main f7de3d6a, retaining both sides of progress docs and all 22
catalogs. Shade freeform points once through the shared paint mapper. Document
release measurements and a reproducible CLI fixture, including the remaining
source-gradient cost and the one-light timing tradeoff.

Validation: full cargo xtask ci passed all 10 steps; 7,978 tests passed, zero
failed and 11 ignored. Four legacy white-light PNGs match unchanged main byte
for byte. Three paired release bench/perf repetitions completed without busy
warnings; all six standard perf runs passed every budget. Two-light solid
Inflate improved from 136.2 to 96.8 ms/frame at 1200x1200, default eight threads.
2026-10-18 01:11:39 +07:00
EricKuckandClaude Opus 5.5 1f304f6a23 Shadows and glows: each is of the art with the ones before it in the stack, and an Outer Glow blurs a solid silhouette
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:11:39 +07:00
makesomethingshit 45c2b0b363 fix(blend): complete contribution checks (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit 85afe7caaf test(blend): preserve Shift reshape after main rebase (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit 92e33b1579 fix(blend): preserve Anchor Point picking priority (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit fa9a52bd71 fix(blend): match cursor, Shift radius and hidden-handle cases (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit 5f70f96a66 style(tools): match rustfmt in rebased spine anchor test (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit 10ae4cc5a8 fix(blend): align spine anchor picking, Shift drag and handle visibility (#2544)
- Prefer the anchor over a short handle within tolerance, like hit_handle.
- Pick the nearest eligible spine point and handle.
- Constrain Shift handle drags to 45-degree steps round the anchor.
- Hide spine handles with handles_multiple off, in overlays and picking.
2026-10-18 01:11:39 +07:00
makesomethingshit eeeb9645c1 docs(blend): document Anchor Point tool spine conversions (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit e1a6e373d5 docs(blend): document Anchor Point tool spine conversions (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit 286a1fb8b3 test(tools): cover blend spine anchor conversion and handle focus (#2544) 2026-10-18 01:11:39 +07:00
MarekSpodBiedry 949473f1e3 Magic Wand: only select objects inside the isolated group (#2655) 2026-10-18 01:11:38 +07:00
MarekSpodBiedry f999688b41 Map the WMF window onto the placeable box (#2673) 2026-10-18 01:11:38 +07:00
MarekSpodBiedry b5164825d6 Write legacy .ai layer names in Windows Latin-1 and warn when a character is replaced (#2693) 2026-10-18 01:11:38 +07:00
Neil Verschoore 56df1a1663 i18n(nl): complete the Dutch catalog
Adds the 39 rows the Dutch catalog was missing compared with the other complete languages.
2026-10-18 01:11:38 +07:00
bflatastic 6a42b3bf10 Fonts: Han and kana keep the bundled fallback order; only the weight and slant of fallback glyphs change (#2491) 2026-10-18 01:11:38 +07:00
makesomethingshit 5b85ca6358 test(blend): pin symmetric spine handles and invalid conversion inputs (#2544) 2026-10-18 01:11:38 +07:00
makesomethingshit e2b4cef111 fix(blend): route Anchor Point tool through spine anchors and handles (#2544) 2026-10-18 01:11:38 +07:00
makesomethingshit f92c622e97 fix(blend): add symmetric spine anchor conversion command (#2544) 2026-10-18 01:11:38 +07:00
vangel76andClaude Opus 5.5 43356a1cfb Fonts: fallback glyphs keep the weight and slant, Bold Italic of a missing family keeps the bold, Han and kana prefer an installed sans (#2491)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:09:54 +07:00
vangel76andClaude Opus 5.5 b55d277b49 Fonts: a variable font's default face is named and weighed by its default instance (#2521)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:09:53 +07:00
Guillem Hermida 2a5dfa6a94 Fix #1876: Artboard Options has X and Y with a reference point
Artboard Options showed Name, Width and Height only: the generic form
hides x and y, which in the shape dialogs are the click position. The
artboard's position could be dragged but not typed.

The dialog now draws its own rows after the form's, in the same grid:
the reference point locator the Transform panel, the Control bar and
Properties share, then X and Y. They show where the reference point
lies, counted from the ruler origin (#1212), as the Transform panel and
the Control bar show an artboard's position. A typed X or Y moves the
artboard so that its reference point lands there, and a new Width or
Height keeps the reference point in place. The fields still hold the
top-left corner in document points, so OK runs artboard.setProps as
before, in one undo step. The shape dialogs keep their x and y hidden.

form::grid_then lets a dialog add rows to the generic grid.
2026-10-18 01:09:52 +07:00
VictorandClaude Opus 5.5 6190525482 G4: Paragraph panel menu › Roman Hanging Punctuation
The Paragraph panel menu's Roman Hanging Punctuation was a disabled stub.

It is now a per-paragraph attribute: in a left-to-right paragraph of point
or area type with it on, the punctuation Optical Margin Alignment hangs
(quotation marks, hyphens, full stops and commas wholly; asterisks,
tildes, ellipses, en and em dashes, colons and semicolons by half) hangs
outside the margin when it starts or ends a line, on the same sides
(left-aligned lines at their left, right-aligned at their right, centered
and justified at both); letters don't hang. With Optical Margin Alignment
on as well, letters hang too. East Asian punctuation keeps hanging only
through burasagari. The layout shares Optical Margin Alignment's hanging
code, given the share of each glyph that hangs.

text.setFormat takes romanHangingPunctuation; it is off by default, saved
only when on, carried by paragraph styles (New and Redefine capture it)
and turned off by Reset Panel. The panel menu item is a checkbox enabled
with type selected.

Docs: docs/mcp.md, docs/type-parity.md, docs/gaps.md.

Closes #2756.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:09:34 +07:00
BFlat f7de3d6a01 Merge pull request #2724 from storytold/pr/batch-1017c
i18n and docs: Image Trace warnings in every complete language; ROADMAP for the evening's landings
2026-10-17 20:03:49 +07:00
bflatastic daaeb45565 Docs: the ROADMAP's 2026-10-17 row includes the evening's landings 2026-10-17 20:03:16 +07:00
bflatastic af555713d5 i18n: the Image Trace large-image warnings in every message-complete language (#2700) 2026-10-17 20:03:16 +07:00
BFlat c1c677bc76 Merge pull request #2409 from dotCooCoo/feat/live-rasterize-effect
Effects: Effect › Rasterize draws an object as pixels while its paths stay editable
2026-10-17 20:02:59 +07:00
BFlat a86cb76d52 Merge pull request #2696 from Silvertree2010/fix/2674-pdf-import-lines-with-line-width
Import PDF 0 w strokes as hairlines instead of dropping them
2026-10-17 20:02:28 +07:00
BFlat 2efd084b97 Merge pull request #2697 from dotCooCoo/feat/task-bar-direct-selection
Contextual Task Bar: direct-selected anchors offer Connect, Corner and Smooth
2026-10-17 20:02:17 +07:00
BFlat 665373b699 Merge pull request #2709 from dotCooCoo/fix/reshape-segment-delta-cap
Paths: Reshape Segment, Convert Anchor Point and the Pencil refuse values of 1e9 pt or more
2026-10-17 20:02:02 +07:00
BFlat 4bf8b3314a Merge pull request #2710 from dotCooCoo/feat/variable-font-axes
Character: variable fonts get a slider for each axis
2026-10-17 20:01:48 +07:00
BFlat 0bb793c079 Merge pull request #2702 from andeecollard/fix/trace-auto-color-rare-colours
Image Trace: Auto Color keeps rare but distinct colours
2026-10-17 20:01:33 +07:00
BFlat 68343ab66a Merge pull request #2700 from andeecollard/fix/trace-large-images-warn
Image Trace: large images trace with a warning; anchor limit 8,000,000
2026-10-17 20:01:18 +07:00
BFlat 4e620ced97 Merge pull request #2701 from andeecollard/fix/raster-new-no-panic
Image Trace: Raster::new returns an error for a buffer of the wrong length
2026-10-17 20:01:04 +07:00
BFlat 15f55f5569 Merge pull request #2705 from andeecollard/trace/centreline-graph
Create Strokes: skeleton graph with junction merging
2026-10-17 20:00:49 +07:00
NetterSilverandClaude Haiku 5.5 fe3e8f65a5 Import PDF 0 w strokes as hairlines instead of dropping them
Fixes #2674

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:38:50 +07:00
dotCooCoo 5ad04faff3 Contextual Task Bar: direct-selected anchors offer Connect, Corner and Smooth
With anchor points selected with Direct Selection, the Contextual Task
Bar's anchor items were Remove Anchor Points and Cut Path. The
reference app's bar also offers to connect the selected end points and
to convert the selected anchors to corner or smooth.

The bar now offers Remove Anchor Points, Connect, Cut Path, Corner and
Smooth. Connect runs Join (path.join), and Corner and Smooth run
path.convertAnchors with `to` set to corner or smooth. These are the
commands of the Control bar's anchor buttons. "Connect" has a row in
every catalog; "Corner" and "Smooth" use the existing rows.

The reference app's Smooth item, which adjusts a path's curvature, is not
offered, because VectorCraft has no Object > Path > Smooth. Simplify,
which the reference app's bar also offers here, is still offered only
while no anchors are selected.

Docs: docs/target-app-parity.md.
2026-10-17 19:38:48 +07:00
dotCooCoo 80b9753866 Paths: Reshape Segment, Convert Anchor Point and the Pencil refuse values of 1e9 pt or more
path.reshapeSegment refused only a delta that was not finite. It moves
a segment's two handles by the delta divided by 3t(1-t), which is
0.1425 at t 0.05 or 0.95, so a finite delta above about 2.5e307 wrote
infinite handles into the path. object.blend.spine.reshapeSegment
refuses a delta of 1e9 pt or longer.

path.reshapeSegment now refuses a delta of 1e9 pt or longer, with its
"invalid delta" error. Two other path commands took a point with no
bound. path.convertAnchor with x, y at 1e308 put the two handles 2e308
apart, so the path's control box was infinitely wide, and path.freehand
through such points with keepSelected off fitted a path whose bounds
reached infinity. Both now refuse a point whose x or y is 1e9 pt or
more in size, with "x and y must be finite", the error
object.blend.spine.moveAnchor gives for the same bound.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 19:38:47 +07:00
dotCooCoo 4712fb2c2f Character: variable fonts get a slider for each axis
VectorCraft listed a variable font's named instances as styles, but
type could not be set between or past them. The reference app's
Character panel has a Variable Font control that adjusts a variable
font's axes, such as Weight, Width and Slant.

Type now keeps Variable Font settings by axis tag
(CharStyle::font_axes, saved as fontAxes only when set). text.setFormat
and text.setRangeStyle take fontAxes: each value is clamped to its
axis's range, a tag the font has no axis for is left out, and null
goes back to the style's own setting. Another font or style clears
them, including one from a character style that does not set them. A
character style captured from type sets them, with {} for none.
text.fontList {family, style} lists a font's axes.

Layout, outlines, the substituted-glyph highlight and PDF export use
the font instanced at the settings, and neighboring runs at different
settings are shaped apart, as runs in different fonts are. SVG export
writes them as font-variation-settings, the wght setting as the weight
and the wdth setting as font-stretch, and embeds the font instanced
there; SVG import reads font-variation-settings back. Legacy .ai export
warns that the settings are left out.

The Character panel shows a Variable Font button for a variable font.
It opens a slider and a field for each axis the font does not hide,
labeled with the font's name for the axis, and screen readers get each
slider under that name. A drag is one undo step. Reset Panel leaves
the settings, as it leaves the font and style.

Missing Glyph Protection now keeps the settings and the font version
on the characters that keep their font; before, it kept only the
family and style. A character style applied or redefined with another
font now clears the old font's version, as text.setStyle does, unless
the style sets one. SVG export with embedded fonts warns when faces of
one weight, style and width share characters, which browsers then draw
in one of the faces.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
2026-10-17 19:38:46 +07:00
andeecollardandClaude Opus 5.5 1e06c82800 Image Trace: Auto Color keeps rare but distinct colours
The Automatic palette counts flat art's colours by area (a colour needs
about 1% of the picture, 0.5% at Colors 100) and fits that count with
median cut and area-weighted k-means. A stem, a highlight, or a near
white beside a pale background (within 24 levels, so in one box) was
averaged into a large neighbour; mixed-colour merging and Noise could
then remove what was left.

On the flat route only, after the count is fitted, rare_colours finds
the colours too rare or too close for it and keeps those that are
distinct (at least 2.5 in Oklab x 100 from every palette colour and from
the main colour each stands for), flat (mostly one histogram bin) and
drawn (a 4-connected region of their own unblended pixels of at least 8
pixels, or three of at least 4); a colour lying between two others, as
anti-aliasing mixes them, must also have an inside. At most six, largest
region first. They stay fixed while the other colours are refitted
around them; merge_mixed leaves them alone; denoise_keeping keeps their
regions of 4 pixels or more whatever Noise is (smaller specks still go).
Full Tone, Limited and Automatic's continuous-tone route are unchanged,
and denoise without kept labels is as before.

Methods cited in the code: median cut (Heckbert 1982), k-means (Lloyd
1982), Oklab (Ottosson 2020), salient-colour preservation (Fukuda et al.
2019, doi:10.1117/12.2521607). The rule and its numbers are VectorCraft's
own; Adobe does not document how its palettes are chosen.

Tests (code-drawn): a rare stem or patch over 2-8 flat colours, hard
and anti-aliased at 1x and 2x; near-white highlights on #FFF4E0 kept and
still paths at Noise 16; a 3 px #909090 stem on #808080; scattered
single pixels of the same areas add no colour; denoise_keeping at Noise
0-32.

Measured on 66 synthetic flat logos (22 designs x hard 1x, anti-aliased
1x and 2x; local inputs): every key colour and no spurious one in 66
(was 39); missing key colours 0 (was 43), spurious 0 (was 2), merged
pairs 0 (was 46); rendered dE00 0.348 -> 0.138. Four public-domain scan
crops take the continuous-tone route and are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 19:38:43 +07:00
andeecollardandClaude Opus 5.5 17a602d1dc Image Trace: large images trace with a warning; anchor limit 8,000,000
The public documentation says Image Trace warns about images larger
than 1500 pixels on a side but traces them, only more slowly. VectorCraft
traced them silently, and refused any trace of more than 2,000,000
anchors, which a 4000 x 3000 photo at High Fidelity Photo nearly reaches
(1.9 million on a code-drawn photo) and a noisier one passes.

- imageTrace.make and makeAndExpand return `warnings`: one for an image
  over 1500 px on a side (traced, but more slowly; a lower-fidelity
  preset or a smaller copy is quicker), one for an image over 64 MP
  that was traced from a scaled-down copy. The app shows the first in
  the status bar.
- MAX_TRACE_ANCHORS is 8,000,000 natively (2,000,000 on wasm, where a
  page has at most 4 GB). Measured through imageTrace.make on
  code-drawn photos: about 90 bytes an anchor kept in the document;
  7.9 million anchors kept 685 MB with an 876 MB peak (4000 x 3000,
  Full Tone at Colors 100, Paths 100, Noise 1, 37 s).
- The refusal keeps its translated advice: raise Noise, lower Paths or
  Colors, or pick a lower-fidelity preset.

Tests: a_large_image_traces_with_a_warning (1600 x 40 and 1500 x 8
code-drawn images, make and make-and-expand),
trace_warnings_name_a_large_or_scaled_down_image, a compile-time check
of the limit's range, and a UI test that the status bar shows the note.

Source:
https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 19:38:18 +07:00
andeecollardandClaude Opus 5.5 a2e2eba2e2 Image Trace: Raster::new returns an error for a buffer of the wrong length
Raster::new checked its RGBA length with assert_eq!, so bytes of the
wrong length (from a file, a plug-in or a control message) would abort
instead of reporting an error. It now returns Result<Raster, TraceError>
and gives TraceError::Decode naming the byte count and the size (the
existing variant, whose text is already translated, instead of a new
message every language would need). The size product is checked for
overflow.

Raster::pixel indexed the bytes directly; it now gives transparent
black outside the raster, or past the end of a raster whose public
fields were set to a size its bytes don't have (which mosaic reads).

The public signature changes: every caller in the workspace was a test
(trace unit tests, Flat Logo tests and the quality harness), updated to
unwrap. No shipped code built a Raster with new; decode, decode_to_fit
and from_fn always make the right length, and trace_within already
refuses a raster whose fields disagree.

Test (code-drawn): a_raster_of_the_wrong_length_is_an_error_not_a_crash
(short, long, empty, zero-width and overflowing sizes; pixel, trace and
mosaic on a raster whose fields disagree).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 19:38:06 +07:00
0594ffe30f M12: build stroke skeleton graphs with junction merging
Place junction clusters at their deepest pixel and prune terminal branches
by significance beyond their maximal discs. Merge overlapping junctions
and split crossings with continuing outer arms, then join degree-two runs.
Bound nodes, branches and simplification work, using checked data access.

Keep whole-blob selection, ink refinement, cap detection and fitting rules.
Test junction degrees, crossings, 24-angle lines, pruning, graph caps and
random-blob determinism. Record improved junction degrees and remaining
spur and extra-junction limitations in the centre-lines measurements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: OpenAI <[email protected]>
2026-10-17 19:37:31 +07:00
abb7c1351b M12: use exact crop distances and Guo–Hall stroke thinning
Compute exact squared Euclidean distances per blob with separable parabola
lower envelopes. Thin with bounded Guo–Hall active sets and retain diagonal
ends during staircase cleanup. Keep the whole-blob width rule and existing
graph, ink, cap and fitting rules, with checked access in the moved module.

Verify EDT against brute force, active sets against plain rounds, and both
diagonal slopes for length and connectivity. Record the centre-lines rerun
and its remaining limitations without changing feature scores.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: OpenAI <[email protected]>
2026-10-17 19:37:28 +07:00
c9f0ed14f0 M12: add centre-line scenes and baseline quality measurements
Measure Create Strokes on exact code-drawn scenes in aa and hard variants,
including centre/width error, topology, ends, spurs and painted coverage.
Keep the existing allocator report in one ignored test and document the
current baseline without changing tracing behaviour or dependencies.

Co-Authored-By: Codex <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 19:37:25 +07:00
BFlat 8c10185ef9 Merge pull request #2714 from storytold/fix/2603-library-panels
Fix #2603: a panel per swatch library, with Persistent
2026-10-17 19:25:40 +07:00
BFlat b9ff56e403 Merge pull request #2713 from storytold/fix/2572-outport-threading
Part of #2572: clicking an area type's out port threads its text into a path or a new frame
2026-10-17 19:25:05 +07:00
bflatastic f38f6fafc3 Fix #2603: a panel per swatch library, with Persistent
Each swatch or graphic style library now opens in a panel of its own,
`library:<kind>:<library id>`, in the shared docking system: several can be open
at once, and they float, group as tabs, dock and close like any panel.
Opening a library that is open brings its panel forward; Previous / Next
Library show the neighbouring library in the same panel.

The library panel menu gains Persistent. The panels' view, Show Find Field
and Persistent are kept in the UI preferences (`library_panels`); at startup
the persistent panels reopen where the saved layout had them, a library
loaded from a file read again from its path (a missing file is skipped with
a status-bar note). `ui.libraryPanel {panel, library?, view?, showFind?,
persistent?}` reaches all of it; docs/mcp.md documents the panels.
2026-10-17 19:13:49 +07:00
bflatastic 88567ee46c Part of #2572: clicking an area type's out port threads its text into a path or a new frame 2026-10-17 19:13:22 +07:00
dotCooCoo 04b0be2c9f Merge main into feat/live-rasterize-effect 2026-10-17 04:09:12 -07:00
BFlat f5511df65f Merge pull request #2695 from storytold/fix/2541-plugins-persist
Fix #2541: installed plug-ins stay installed after a restart
2026-10-17 18:04:04 +07:00
bflatastic 2879c206a8 Fix #2541: installed plug-ins stay installed after a restart
Install Plug-in (plugin.install) now keeps the plug-in's module in a
Plug-ins folder next to the preferences, as Illustrator keeps plug-ins in
its Plug-ins folder, and the desktop app and vectorcraft-cli install the
ones there at startup (a module that doesn't load is skipped with a log
record, at most 512 MB are read). Remove Plug-in deletes it from the
folder. The Additional Plug-ins Folder preference is still loaded from
where it is. Confined MCP servers and VECTORCRAFT_NO_PREFS keep nothing,
as with the PDF preset and colour profile stores.

Docs: docs/mcp.md, docs/plugins.md, docs/target-app-parity.md.
2026-10-17 17:49:38 +07:00
BFlat e78116e0a2 Merge pull request #2609 from dotCooCoo/fix/history-labels-translated
i18n: the History panel translates the undo step names the sources write as literals
2026-10-17 17:41:28 +07:00
bflatastic 351612c978 i18n: Dutch, Danish and Indonesian undo-step names for the History panel (#2609) 2026-10-17 17:26:59 +07:00
dotCooCoo 7930a0456b i18n: the History panel translates the undo step names the sources write as literals
The History panel looked up each step's name with tl!, which reads only
a plain catalog row. 95 of the 427 names that the engine, the tools and
the shell give their undo steps had no such row in at least one complete
catalog, and 83 had no row at all (Make Blend, New Guide, Pen, Reshape
Spine and others). A step named after an effect has the effect's menu
name without its ellipsis. For many effects, and for steps such as New
Layer, the catalogs hold that name only with the ellipsis. The panel
listed those steps in English.

Each complete catalog now has a row for every step name, written in the
catalog's own terms: 1,437 rows in the 19 catalogs. The History panel
reads a step's row, or else the row of the same name with an ellipsis,
without the ellipsis, and shows the name as recorded when the catalog
has neither. A new test reads the step names from the sources, as the
tl! and message tests read theirs, and requires a row for each in every
complete catalog.

Docs: docs/target-app-parity.md.
2026-10-17 17:22:15 +07:00
BFlat fdac986bb4 Merge pull request #2686 from storytold/fix/2441-group-select-compound-part
Part of #2441: Group Selection picks and moves one path of a compound path; i18n and docs for the sixth batch
2026-10-17 17:18:26 +07:00
BFlat fcdddadeb8 Merge pull request #2625 from dotCooCoo/fix/puppet-pins-keep-save-clean
Background save: dropped Puppet Warp pins, ended Edit Text and font scans leave the document saved
2026-10-17 17:17:51 +07:00
BFlat 97cd86d461 Merge pull request #2626 from dotCooCoo/feat/artboards-panel-shift-range
Artboards: Shift-click in the Artboards panel selects a range of artboards
2026-10-17 17:17:36 +07:00
BFlat 47b1fa52cb Merge pull request #2628 from dotCooCoo/feat/export-overflow-warning
Export: PDF, EPS, SVG, EMF, WMF and image exports warn about type that doesn't fit its frame
2026-10-17 17:17:22 +07:00
BFlat 21dc3bf565 Merge pull request #2629 from dotCooCoo/fix/exit-isolation-keeps-lock-guides
Guides: Lock Guides keeps guides deselected after Undo, Exit Isolation Mode and Paste
2026-10-17 17:17:08 +07:00
BFlat 6ecf568c5d Merge pull request #2630 from dotCooCoo/fix/issue-2345
Part of #2345: a marquee does not select a clip group by its hidden art
2026-10-17 17:16:50 +07:00
BFlat 7d677eaa13 Merge pull request #2631 from dotCooCoo/feat/lock-variables
Variables: Lock Variables stops variables from being defined, deleted or renamed
2026-10-17 17:16:35 +07:00
BFlat 20a40f5b1b Merge pull request #2632 from MarekSpodBiedry/issue-2260
Layers panel: drop a layer under the last row to move it to the bottom (#2260)
2026-10-17 17:16:18 +07:00
BFlat e3a67119c1 Merge pull request #2633 from dotCooCoo/fix/save-panels-offer-their-file-type
Save: save panels for files other than documents offer the file type they write
2026-10-17 17:16:01 +07:00
BFlat 003ec8f373 Merge pull request #2635 from dotCooCoo/fix/issue-2460
Part of #2460: a huge selection of type, images, symbols or meshes shows its bounds
2026-10-17 17:15:52 +07:00
BFlat 1fd7ab495a Merge pull request #2665 from dotCooCoo/fix/print-tiling-artboard-edge
Print: a Print Tiling origin on an artboard's right or bottom edge is measured from that artboard
2026-10-17 17:15:35 +07:00
BFlat e7e149a8bd Merge pull request #2637 from dotCooCoo/fix/art-on-artboard-edge
Artboards: art centered on an artboard's right or bottom edge is on that artboard
2026-10-17 17:15:19 +07:00
bflatastic 4417c4feb7 Docs: the ROADMAP's 2026-10-17 row includes the sixth batch 2026-10-17 17:13:04 +07:00
dotCooCoo b0e78cf3d8 Background save: dropped Puppet Warp pins, ended Edit Text and font scans leave the document saved
Selecting other art drops Puppet Warp pins, ending Edit Text clears the
flat view, and a font scan lays type out again. None of these makes an
undo step or counts as an edit to save, but each one replaced the
document. A background save that finished afterwards compared the
document with the one it wrote by pointer, so the document stayed
marked modified. Data Recovery also wrote a new copy of a document whose
pins had been dropped.

DocState now records the documents that these changes replaced, and
DocState::same_content counts them as having the same content. The
modified flag, the end of a background save and Data Recovery use it.
After Edit Text ends, an undo state that was the saved one stays the
saved one.
2026-10-17 17:13:03 +07:00
dotCooCoo 1401051603 Artboards: Shift-click in the Artboards panel selects a range of artboards
With the Artboard tool in use, a Shift-click on a row of the Artboards
panel selects the artboards from the last row clicked without Shift to
the clicked row, and the clicked row's artboard becomes the active one.
When the artboard of the last row clicked without Shift is no longer
selected, the range starts from the active artboard, and later
Shift-clicks start from there too. Ctrl/Cmd-click still adds or removes
one artboard, and a plain click selects one. The public Select
artboards page lists Shift-click in the Artboards panel for selecting
several artboards.

A Shift-click used to add or remove one artboard, as Ctrl/Cmd-click
does (#1139).

Docs: docs/target-app-parity.md.
2026-10-17 17:13:03 +07:00
dotCooCoo 14a3740e51 Export: PDF, EPS, SVG, EMF, WMF and image exports warn about type that doesn't fit its frame
Print and Print Preview name the area type and type on a path that
prints without the text past the end of its frame (#2308). The exports
that draw type gave no such warning: a PDF, EPS, SVG, EMF, WMF or
raster export of a frame too small for its text answered with no
warning about it.

These exports now add the same warning, with "isn't drawn in the
export" in place of "doesn't print". Each export counts the visible
objects that it draws. Those are the objects on the pages or artboards
it writes, or all of them in an EPS of the visible art and in an SVG
without chosen artboards. A PDF leaves out layers that don't print
unless includeNonPrinting or createLayers is set. The objects are found
with the walk that fonts::printed_type runs for Print, and typecmd.rs
builds the message with the code that builds Print's. A .ai file keeps
the whole text in its editing data and gives no warning.

Docs: docs/mcp.md (Fitting area type), docs/target-app-parity.md.
2026-10-17 17:13:03 +07:00
dotCooCoo 30c5bd3589 Guides: Lock Guides keeps guides deselected after Undo, Exit Isolation Mode and Paste
With View > Guides > Lock Guides on, Session::select drops the paths
made into guides from the selection (#1534), but commands that set the
selection another way skipped that filter and left a locked guide
selected, so the next move moved it. object.exitIsolation selected an
isolated guide path. Undo, Redo, a canceled undo group, a canceled
drag or dialog preview and a failed command.batch brought back locked
guide paths and ruler guides in the selection. Unlock All, Show All
and Paste selected the guide paths they unlocked, showed or pasted.
Locking the guides deselected them in the active document only.

The filter is now deselect_guides, which also clears the selected
ruler guides. Session::select and Session::edit run it while Lock
Guides is on, and so do object.exitIsolation, Undo, Redo,
Session::cancel_interaction, a canceled undo group and a failed batch.
Locking the guides deselects them in every open document. With Lock
Guides off, the selection is set as before.

Docs: docs/mcp.md.
2026-10-17 17:13:03 +07:00
dotCooCoo e09038041f Part of #2345: a marquee does not select a clip group by its hidden art
A marquee measured a clip group, and the objects on a layer with a
clipping mask, by all of their art, including the art the mask hides.
A marquee around a rectangle drawn next to a clip group also selected
the group when it crossed the group's hidden art. A click already
counts only the art inside the clipping path.

The marquee now takes a clip group, or an object on a clipping layer
or sublayer, only when it meets the area inside the clipping path, and
it measures the art there within that area's bounds. The same rule
applies to the objects that Direct and Group Selection select whole,
to Group Selection's E marquee, and in isolation mode when the clip
group or clipping layer itself, or one object directly inside it, is
isolated. The anchor points that Direct Selection's marquee picks are
unchanged.

Docs: docs/target-app-parity.md.
2026-10-17 17:13:03 +07:00
dotCooCoo 59f63e9161 Variables: Lock Variables stops variables from being defined, deleted or renamed
Nothing locked the variables: any command or panel button could delete
or rename a variable that scripts and data sets name.

variable.lock {locked?} (Lock Variables: a lock button in the Variables
panel's bottom bar and an item in its menu) sets a flag that is saved
with the document. Without `locked` it toggles, and a change is one
undo step. While the flag is set, variable.define, variable.delete,
variable.rename and the four variable.make…Dynamic commands are refused
with "variables are locked", and the panel disables New Variable…,
Variable Options…, Delete Variable and Make … Dynamic. Binding,
unbinding and the dataset commands still run. variable.list
reports `locked`. A file without the flag opens unlocked.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 17:13:03 +07:00
MarekSpodBiedry 336d860ed3 Layers panel: drop a layer under the last row to move it to the bottom (#2260) 2026-10-17 17:13:03 +07:00
dotCooCoo 7bc9fd7220 Save: save panels for files other than documents offer the file type they write
Document Info's Save, Save Swatch Library and Save Graphic Style
Library to a file, the Libraries panel's Export Library…, the presets
dialogs' Export…, the CSS Properties export, Print to a PostScript
file and Keyboard Shortcuts › Export… passed no file type to the save
panel. On Windows the panel listed "All Files", and neither the Windows
nor the macOS panel added the extension to a name typed without one.

Each of these saves now passes the one file type it writes, as Save and
Export do for document formats. The Windows panel then lists it, and
the Windows and macOS panels add its extension. The labels are the ones
the open dialogs use for these files (Swatch libraries, Libraries, the
presets, Shortcut sets), the Window menu's Graphic Style Libraries, Text
for Document Info, CSS and PostScript.
2026-10-17 17:13:03 +07:00
dotCooCoo cec7d23d96 Part of #2460: a huge selection of type, images, symbols or meshes shows its bounds
A selection with more than 100,000 anchors shows its bounds instead of
every outline and anchor (#525). The count took in path anchors only.
Type, images, symbol instances, placed documents, gradient meshes and
envelopes counted nothing, although the selection overlay outlines each
of them: a box, an area type frame or a type path, a baseline marker
for point type, and the points and mesh lines of meshes and envelopes.
The PDF importer makes type objects from a page's text lines, one image
object per image and one gradient mesh per patch or triangle of a mesh
shading, so selecting all of a large PDF outlined each of them: 131,072
selected mesh pieces need 16.8 million vertices, a 336 MB vertex
buffer, past wgpu's 256 MiB buffer limit.

The count now takes in what the overlay draws for each kind. Paths
count their anchors. Area type and type on a path count the anchors of
their frame or path, and point type counts six: its box's corners and
the ends of its baseline marker. Images, symbol instances, placed
documents, and area type or type on a path in perspective count the
four corners of their box, and gradient meshes and envelopes count
their points and the ends of their mesh lines. The selection's outline,
the key object's outline and the hover highlight all use the count.

Docs: docs/target-app-parity.md.
2026-10-17 17:13:03 +07:00
bflatastic c18069e6ed Print Tiling: the origin's artboard is found with the document's own edge rule (#2637) 2026-10-17 17:13:03 +07:00
bflatastic fb8578ddf0 i18n: rows for the strings this batch adds (Danish, Dutch, Indonesian labels; gradient-mesh image messages in every complete language) 2026-10-17 17:13:03 +07:00
bflatastic 546b8846b6 Part of #2441: Group Selection picks and moves one path of a compound path
A click with the Group Selection tool on a compound path selects the path
under the pointer (its outline within the pick tolerance, else the innermost
path whose area holds the click), so the outer shape and each hole can be
selected on their own; Shift-click adds another, and a click on a selected
path adds the compound path, as a click on a group member climbs to its group.

A path selected this way is an object of its own for Move, Transform, Rotate
and the other transforms, as it already was inside an isolated compound path:
the hole of a ring moves without the ring. Paint and the other commands still
go to the compound path, which holds the paint.

Docs: docs/target-app-parity.md, docs/ui-parity.md.

# Conflicts:
#	docs/target-app-parity.md
2026-10-17 17:13:03 +07:00
dotCooCoo 24b6486ebd Artboards: the doc comment names the shared corner the first artboard holds
On an edge two artboards share, the artboard to the right of it or below
it holds a point, except at the end of the edge that lies on that
artboard's bottom or right edge, such as where both artboards' bottom
edges meet. There the first artboard holds it, or the last when
topmost. The doc comment and the test comments now say so, and the test
asserts that corner.
2026-10-17 17:13:02 +07:00
dotCooCoo d22ccebc9c Print: a Print Tiling origin on an artboard's right or bottom edge is measured from that artboard
The Print Tiling tool's press and print.tiling.set without `artboard`
decided which artboard a point is on with Rect::contains, which leaves
out the right and bottom edges. A press exactly on an artboard's right
or bottom edge picked no artboard when no other artboard holds that
point, so the drag did not snap to that artboard's edges. An origin
there was measured from the first printed artboard: with artboards at
0 to 300 pt and 300 to 600 pt, an origin on the second one's right edge
was saved as x 600 instead of 300.

Both now count the edges as Document::art_on_artboard does, within a
millionth of a point.
2026-10-17 17:13:02 +07:00
BFlat fa4e42762b Merge pull request #2614 from dotCooCoo/fix/artboard-art-symbol-bounds
Artboards: Rearrange and Select All on Active Artboard measure a symbol instance by its art
2026-10-17 17:12:16 +07:00
BFlat 2045fe3de1 Merge pull request #2638 from dotCooCoo/fix/spine-whole-number-index
Blends, meshes and envelopes: an index or count that is not a whole number is refused
2026-10-17 17:12:05 +07:00
BFlat eb672e8f42 Merge pull request #2641 from dotCooCoo/fix/data-sets-rewrite-hidden-type
Variables: a data set rewrites bound type on hidden layers
2026-10-17 17:11:53 +07:00
BFlat 82e2c9506b Merge pull request #2644 from dotCooCoo/feat/trackpad-rotate-view
Rotate View: a two-finger rotation on a Mac trackpad turns the view about the pointer
2026-10-17 17:11:41 +07:00
BFlat fff1acac06 Merge pull request #2645 from dotCooCoo/fix/huge-selection-type-path-brackets
Type: a huge selection of type on a path draws no per-object brackets
2026-10-17 17:11:25 +07:00
BFlat 8186f9e2e4 Merge pull request #2647 from dotCooCoo/fix/too-large-note-counts-rgba
Images: the too-large note counts the image's pixels as RGBA
2026-10-17 17:11:14 +07:00
BFlat 5f0d809047 Merge pull request #2648 from dotCooCoo/fix/rasterize-warns-undecodable-image
Rasterize, Flatten Transparency: commands that replace art with a rendering warn about an undecodable image
2026-10-17 17:10:58 +07:00
BFlat f19a71367d Merge pull request #2650 from dotCooCoo/fix/issue-2422
Part of #2422: a selected clip group shows its clipping path's outline, not its hidden art
2026-10-17 17:10:48 +07:00
BFlat 49686460aa Merge pull request #2656 from dotCooCoo/fix/saved-bleed-and-spacing-in-range
New Document and Document Setup: values out of range in a saved preset or a file are read in range
2026-10-17 17:10:33 +07:00
BFlat 8ecb22851d Merge pull request #2657 from Silvertree2010/fix/h2438-css-svg-export-writes-an-invalid
Escape quotes and backslashes in exported CSS font-family
2026-10-17 17:10:20 +07:00
BFlat 2aa7412e3a Merge pull request #2659 from dotCooCoo/fix/object-mosaic-whole-counts
Object Mosaic: columns and rows that are not whole numbers are refused
2026-10-17 17:10:01 +07:00
BFlat 18d01ae6ff Merge pull request #2664 from dotCooCoo/fix/issue-2276
Fix #2276: the Eyedropper and RGB input give color-managed CMYK values in a process document
2026-10-17 17:09:45 +07:00
BFlat 3c5034b3dd Merge pull request #2667 from dotCooCoo/fix/ai-import-stale-outline-note
.ai import: type from the layers opens without the PDF part's outlines warnings
2026-10-17 17:09:30 +07:00
BFlat 1168b2373f Merge pull request #2538 from vangel76/fix/1676-mesh-from-image
Gradient mesh: Create Gradient Mesh on an embedded image (#1676)
2026-10-17 17:09:15 +07:00
BFlat 03e867df02 Merge pull request #2512 from vangel76/feat/2342-symbol-nine-slice
Symbols: Symbol Options › Enable Guides for 9-Slice Scaling (#2342)
2026-10-17 17:08:56 +07:00
BFlat 0c0267acbf Merge pull request #2416 from VL420/feat/global-rulers
G7: View › Rulers › Change to Global / Artboard Rulers
2026-10-17 17:08:45 +07:00
BFlat 3e9c590ee4 Merge pull request #2582 from dotCooCoo/fix/appearance-inline-choice-params
Appearance: the inline effect editor shows choice parameters as dropdowns with the dialog's labels
2026-10-17 17:08:32 +07:00
BFlat 06f0700155 Merge pull request #2594 from dotCooCoo/fix/rotated-view-cursors
Rotate View: resize and rotate cursors turn with the view
2026-10-17 17:08:15 +07:00
BFlat 3981e0bd03 Merge pull request #2453 from VL420/feat/artistic-filters-1
G2: Effect › Artistic › Cutout, Neon Glow and Poster Edges
2026-10-17 17:08:03 +07:00
BFlat 37f578ff34 Merge pull request #2472 from Janiz-zzz/fi-review-20261016
i18n(fi): review the machine-drafted Finnish rows
2026-10-17 17:07:53 +07:00
dotCooCoo 8f87ad53dd Artboards: art centered on an artboard's right or bottom edge is on that artboard
Rearrange All Artboards, Select › All on Active Artboard (for objects
with no area), Copy's source artboard for Paste in Place, Align to
Artboard without an index, Crop Image, the Crop Image tool, Place with
`at`, CSS positions and a filter plug-in's artboard decided whether a
point or an object's center is on an artboard with Rect::contains,
which leaves out the right and bottom edges. A line along an artboard's right or
bottom edge was on no artboard: Rearrange left it behind, and All on
Active Artboard did not select it. Document::art_on_artboard counts
art flush with those edges (#890).

They now count the edges as art_on_artboard does, within a millionth
of a point. On an edge two artboards share, the artboard to the right
of it or below it still holds the point. All on Active Artboard
selects a line on a shared edge from either artboard. A press exactly
on a right or bottom edge now picks that artboard, and the Artboard
tool's cursor shows Move there.
2026-10-17 16:38:43 +07:00
dotCooCoo 67b47c7262 Artboards: Rearrange and Select All on Active Artboard measure a symbol instance by its art
Rearrange and Select › All on Active Artboard measured a symbol
instance as its transform applied to the 20 × 20 square around the
symbol's origin, not by the symbol's art. An instance whose art lies
away from that square moved with, or was selected on, the artboard
the square was on. Convert to Artboards, Transform Each, Lock and
Hide › All Artwork Above, Paste and Place from Library at a point,
and the clipboard imports measured the instance the same way, as did
Copy and Cut when they recorded the source artboard and library.add
when it sized a graphic.

These commands now measure an instance by its art through
Document::symbol_boxes, as Document::art_on_artboard, Fit to Artwork
Bounds and the Align commands do. Clipboard::bounds measures with the
symbols the clipboard holds. clipboard.exportPng, clipboard.exportPdf
and the PNG and PDF that Copy offers other apps are now cropped to
the instance's art.

Docs: docs/mcp.md.
2026-10-17 16:38:43 +07:00
dotCooCoo 1a9509b776 Blends, meshes and envelopes: an index or count that is not a whole number is refused
The blend spine commands and the gradient mesh point commands read
their anchor, segment or index as a number and dropped its fraction,
so object.blend.spine.moveAnchor with anchor 1.5 moved point 1 and
reported success. object.envelope.setMeshPoint did the same and also
read a negative index as 0. Specified Steps in object.blend.make and
object.blend.options, and the rows and columns of object.mesh.create,
object.envelope.makeWithMesh and resetWithMesh, dropped their fraction
in the same way.

These commands now fail with "`<key>` must be a whole number" for a
fraction, and object.envelope.setMeshPoint fails with "index must be
≥ 0" for a negative index, as the other point commands do. Whole
numbers written as 4.0, as the Blend Options and Make with Mesh
dialogs send them, still work.

Docs: docs/target-app-parity.md.
2026-10-17 16:38:43 +07:00
dotCooCoo 5669e02331 Variables: a data set rewrites bound type on hidden layers
dataset.select skipped every bound object inside a hidden layer or
group. Type on a hidden layer kept the previous row's text, which
showed when the layer was shown again, and dataset.list and the
Variables panel reported every row as matching that type while the
layer stayed hidden.

dataset.select, dataset.next and dataset.prev now rewrite type and
apply the other values on hidden objects and inside hidden layers and
groups. Those objects stay hidden unless a visibility value shows
them. Objects that are locked or inside a locked layer or group are
still skipped and counted in `skipped`. dataset.list, dataset.capture
and dataset.update read hidden objects as well. Binding still refuses
art inside a hidden layer or group.

The values now go in the order the variables are defined. The passes
that applied visibility values before and after the other values are
removed.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 16:38:43 +07:00
dotCooCoo 4285ccdda3 Rotate View: a two-finger rotation on a Mac trackpad turns the view about the pointer
A two-finger rotation on a Mac trackpad did not turn the view. With
the new General preference Trackpad Gesture to Rotate View
(`trackpadRotateView`, on by default) and the pointer over the canvas,
the rotation turns the view about the pointer, keeping the document
point under it in place. The view does not turn until the rotation
reaches 5°; it then turns by the whole rotation and follows each later
rotation event. A rotation ends 0.25 s after its last event. A rotation
in the same frame as a pinch zooms and turns about the same point.

egui reports the gesture as `Event::Rotate`: egui-winit makes it from
winit's macOS rotation gesture, and eframe makes it from Safari's
gesture events in the web build. Rotation events that arrive while
fingers are on a touch screen do not turn the view. With the
preference off, the gesture does not turn the view.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/hardware-parity.md.
2026-10-17 16:38:42 +07:00
dotCooCoo c5405479b9 Type: a huge selection of type on a path draws no per-object brackets
The Selection and Direct Selection tools drew the start, center and end
brackets of every selected type on a path, and hit-tested each of
them, however large the selection was: 32,768 selected type on a path
objects added 1,310,720 vertices to the frame's mesh, 40 for each
object. The PDF importer makes type on a path for curved text lines,
so selecting all of a large PDF can reach that size.

With more than 10,000 type on a path objects selected, the tools now
neither draw nor hit-test any bracket: a press where a bracket would
be does not start a bracket drag, and the pointer does not change to
the bracket cursor there. The tools draw 10 points for each object's
brackets, so 10,000 objects take 100,000, the same number as the
canvas's limit on the anchor points it outlines. The tools crate has
its own constant with the same value.

Docs: docs/target-app-parity.md.
2026-10-17 16:38:42 +07:00
dotCooCoo df16d21e84 Images: the too-large note counts the image's pixels as RGBA
File › Open, File › Place and the Links panel note an image that needs
more than the decode allowance (2 GiB, 512 MiB in the web build). The
note counted the bytes the decoder gives, one a pixel for an 8-bit or
1-bit gray image, so a 1-bit gray PNG of 23,171 × 23,170 pixels (537 MB
as gray, 2,147,488,280 bytes as RGBA) got no note, although sampling,
recoloring and Load Texture refuse it for its RGBA size.

The note now counts width × height × 4, or the decoder's bytes when
they are more, against the allowance, with the size computed in 64
bits. Such an image gets the note an oversized RGBA image gets. Images
within the allowance get no note, as before.
2026-10-17 16:38:42 +07:00
dotCooCoo 133fd73feb Rasterize, Flatten Transparency: commands that replace art with a rendering warn about an undecodable image
Object > Rasterize, Crop Image, Flatten Transparency, Expand
Appearance of raster effects and Vector Halftone draw the art with the
renderer and replace it with the result. When the art held an image
that could not be decoded, the renderer drew nothing for it, and the
result lacked the image with no message. A raster export of the same
art warns that "an image couldn't be decoded and is missing from the
picture".

These five commands now compare decode_failures before and after they
draw, as raster exports do, and their replies carry that text as
`warning`. They still make their result in one undo step. The app
shows a command's `warning` in the status bar: after a menu command,
after OK in a dialog with a preview, and after a tool runs a command
(Crop Image's Apply).

Docs: docs/mcp.md (Flatten Transparency, Expand Appearance, Document
Raster Effects Settings, Vector halftone).
2026-10-17 16:38:42 +07:00
dotCooCoo 8ee5f96da5 Part of #2422: a selected clip group shows its clipping path's outline, not its hidden art
A selected clip group drew the outline of everything inside it: the
clipping path, the whole rectangle of a clipped image, and the whole
outline and anchors of each clipped path. The Selection tool's
bounding box, which fits the clipping path, showed on top of them.
The hover highlight and the key object outline drew the same hidden
art.

The selection overlay, the hover highlight and the key object outline
now show only the clipping path of a clip group or clipping layer,
its first child. The overlay's limit on anchors counts only that path
too, so a clip group that hides a large trace still shows its
clipping path's anchors and hover highlight. The art inside still
shows its whole outline when it is selected on its own: with Edit
Contents, a Direct or Group Selection click, or in isolation mode.

Docs: docs/target-app-parity.md.
2026-10-17 16:38:42 +07:00
dotCooCoo e74b09c86d New Document and Document Setup: values out of range in a saved preset or a file are read in range
file.new takes a width and height up to 163,830 pt, 1 to 1,000
artboards, a spacing of 0 to 100,000 pt and a bleed of 0 to 72 pt a
side, but a saved or Recent New Document preset in the preferences
was read as it was stored. With a width of -5, file.new failed with
"width/height must be positive and within the canvas"; a spacing of
-50 overlapped the artboards, a preset with 5,000 artboards made 5,000
artboards, and a bleed of 100 reached the document. A .vectorcraft
file's Document Setup bleed, superscript and subscript proportions and
small caps size also opened as stored, and document.setup then failed
when its own report was passed back to it.

These values are now read as the nearer end of the range their
setter takes, and an artboard width or height of 0 or less is read as
1 pt. Presets and files with values in range read as before.

Docs: docs/mcp.md (Document Setup, New Document).
2026-10-17 16:38:42 +07:00
NetterSilverandClaude Haiku 5.5 a631a0df6c Escape quotes and backslashes in exported CSS font-family
Found while testing crates/svg/src/css.rs:84 (font_props)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 16:38:42 +07:00
dotCooCoo d18135bfcd Object Mosaic: columns and rows that are not whole numbers are refused
object.createObjectMosaic read columns and rows with as_u64 and fell
back to 10 for anything else, so 2.5, -3 or "4" made a 10 x 10 mosaic
and reported success. Transform Each read reference and seed the same
way (a reference of 2.5 used the center point, and a seed of 7.5 used
the default seed), and Rearrange All Artboards cut 1.5 columns to 1
and read "1" as the default 2.

These five parameters now take a whole number, written 4 or 4.0 as the
Rearrange All Artboards dialog sends it. A fraction or a negative
number is refused with "`columns` must be a whole number", the message
object.slice.divide gives. A string or another value that is not a
number is refused with the check_numbers error. The command then
changes nothing. The existing ranges stay: 1 to 1000 mosaic tiles
across and down, a reference of at most 8, and 1 to the artboard
count of columns.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 16:38:42 +07:00
dotCooCoo 3adf5141e7 Fix #2276: the Eyedropper and RGB input give color-managed CMYK values in a process document
In a CMYK document, an RGB color from the Eyedropper, a hex value or
paint.setFill was stored as CMYK by the profile-free formula
(k = 1 - max(r, g, b)), not through the color settings. RGB 83/170/223
came in as C63 M24 Y0 K13. A CMYK image was sampled through the RGB its
decoder shows, so its inks came back only when the formula happened to
round-trip them.

paint_from now converts an RGB color into a CMYK document with
Cms::convert and the settings' intent, as Document Color Mode and Swatch
Options already do. This covers paint.setFill and paint.setStroke,
gradient stops, swatch.new {color} and the Eyedropper. With Generic CMYK,
83/170/223 now separates to C59 M12 Y3 K0, and #ff0000 now separates to
C0 M97 Y100 K0 instead of C0 M100 Y100 K0. keepModel: true still keeps
RGB. paint.sampleColor with a stop stores the color the same way.

The Eyedropper samples a CMYK image's ink amounts (a four-component
JPEG, or an 8- or 16-bit chunky CMYK TIFF) and returns them unchanged
(ImageBlob::sample_cmyk_object), also in an RGB document. It takes an
RGB image's sRGB pixel into the working RGB space, as the screen color
sampler does, so the picked color shows as the pixel when the working
space is not sRGB.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 16:38:42 +07:00
dotCooCoo 1651dadf41 .ai import: type from the layers opens without the PDF part's outlines warnings
A PDF-compatible .ai read through its editing data kept the PDF part's
warnings that text was kept as outlines ("text was converted to
outlines", the U+FFFD warning and the warning on glyphs that differ
from the installed font). The layers take from the page only text
nodes and the stroked paths around them, so filled outlined glyphs
don't reach the document, and type made from the text document holds
every character.

- The warnings are dropped when the document holds no glyphs kept as
  outlines. Stroked glyphs that come in with stroked type keep them.
- A text object that takes the page's type, next to glyphs the page has
  only as outlines, lacks those characters: a new warning counts such
  objects.
- vectorcraft_pdf exports the three warnings, is_outlines_note,
  is_glyph_outlines and holds_outlines.

Docs: docs/target-app-parity.md.
2026-10-17 16:38:41 +07:00
vangel76andClaude Opus 5.5 e4ad256d7d Gradient mesh: Object > Create Gradient Mesh on an embedded image (#1676)
The mesh covers the image's rectangle with its transform kept; each point takes the average colour and opacity of the picture around it. A linked image is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:38:41 +07:00
vangel76andClaude Opus 5.5 c692962a22 Symbols: fuzz nineSlice params of symbol.new and symbol.options (#2342)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:38:41 +07:00
VictorandClaude Opus 5.5 276eeacae6 G7: View › Rulers › Change to Global / Artboard Rulers
View › Rulers › Change to Global Rulers (Cmd+Alt+R) was a disabled
stub: the rulers always counted from one document zero point. The
reference app's public help describes two modes: artboard rulers, the
default, count from the active artboard's top-left corner, and each
artboard can have its own zero point; global rulers keep one zero point
for the whole document whichever artboard is active.
https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html
https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html

`view.globalRulers {global?}` switches the mode (toggle by default),
undoable and saved with the document (`rulers`). The menu item reads
Change to Artboard Rulers while global rulers are on. A new document has
artboard rulers; a file saved before the mode existed has no `rulers`
key and opens with global rulers, so it measures as it did.

With artboard rulers, dragging the ruler corner sets the active
artboard's zero point, kept as an offset from its corner
(`artboard_ruler_origins`, by artboard id) so it moves with the
artboard, and double-clicking the corner resets it. The global zero
point (`ruler_origin`) stays separate. `document.rulerOrigin`,
`document.setRulerOrigin` and `document.resetRulerOrigin` take
`artboard`; `Document::ruler_origin_for` is the zero point with an
artboard active, falling back to the corner or the global one when a
stored value is out of range.

The rulers, the Transform, Properties and Control bar X/Y, the Info
panel's X/Y (which ignored the zero point before), ruler-guide
readouts and Shift ruler-tick snapping all count from that zero point:
the view's active artboard reaches the tools through `ViewInfo` and
`ToolContext::ruler_origin`. Command coordinates stay absolute.

Tests: engine/tests/ruler_origin.rs (default mode, per-artboard origin
following its artboard, global origin apart, toggle and undo, save and
legacy files, out-of-range values), ui-egui/src/tests_ruler_origin.rs
(position fields and corner drag on the active artboard, menu label and
shortcut), tools/src/rulerguide.rs (guide readouts and tick snapping).
Tests written for the single global zero point now turn global rulers
on first. "Change to Artboard Rulers" is translated in every catalog.

Docs: docs/ui-parity.md, docs/gaps.md (G7), docs/target-app-parity.md,
docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01U2JbUtM4MkzWLm9FgYiEsq
2026-10-17 16:38:41 +07:00
vangel76andClaude Opus 5.5 3eed742639 Symbols: Symbol Options › Enable Guides for 9-Slice Scaling (#2342)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:38:41 +07:00
bflatastic 5b1e18f2fc Appearance: the inline editor's checkboxes take their label by reference, as check_box now names them (#2611) 2026-10-17 16:38:40 +07:00
dotCooCoo baec72c0fe Appearance: the inline effect editor shows choice parameters as dropdowns with the dialog's labels
The Appearance panel's inline effect editor showed a parameter that
picks one of some values (Roughen's Points, Offset Path's Joins, the
menus of Smart Blur, Glass, Texturizer and De-Interlace) as a text
field holding the stored value, where the effect's dialog has a
dropdown. Its labels did not have the dialog's mappings ("Channel1"
and "Max Radius" for "Channel 1:" and "Max. Radius:"), and a plug-in
choice named mode whose value is a blend mode name showed the blend
mode list.

The editor now shows the other parameters that effect::choices lists
as dropdowns with the dialog's labels (Drop Shadow's Shade keeps its
radio buttons), translated in the "effect" context, and a pick sends
the dialog's value with effect.setParams. Glass's Texture menu ends
with Loaded Texture once a texture is loaded. A plug-in's choice lists
the plug-in's values, and the blend mode dropdown is only for our own
effects. The labels are form::humanize's without the colon. The dialog
and the panel both call form::choice_label and effect::glass_textures.

Docs: docs/target-app-parity.md.
2026-10-17 16:18:32 +07:00
dotCooCoo a12af589aa Rotate View: resize and rotate cursors turn with the view
In a rotated view the resize and rotate cursors were the ones for an
unturned view. With the view turned 90°, the right-middle handle of a
selection's bounding box shows at the middle of the box's bottom edge
and drags up and down, but its cursor was the left-right arrow. This
affected the Selection, Free Transform, Artboard, Slice Selection, Crop
Image, Perspective Grid and Perspective Selection tools, ruler guides,
and the spread handle of a freeform gradient.

Now each of these cursors turns with the view to the nearest 45°. The
Selection tool and the Perspective Selection tool turn the handle's
direction by the view's angle before they round it to a cursor.
ToolContext::view_angle reads the view's angle from the screen frame,
and Cursor::turned turns a resize or rotate cursor by it. The new
Cursor::rotate_about_in_view turns the pointer's direction from the
center by the view before it picks one of the rotate cursor's eight
directions. Cursor::rotate_about keeps its signature and behavior. At
0° every cursor is as it was.

Docs: docs/target-app-parity.md.
2026-10-17 16:18:28 +07:00
VictorandClaude Opus 5.5 d1a6e39b7e G2: Effect › Artistic › Cutout, Neon Glow and Poster Edges
Three original Artistic filters on the pixel pipeline, each with a
catalogue row, the generic live dialog, translations and tests.

- Cutout: colours softened by Edge Simplicity (less with Edge Fidelity),
  split into Number of Levels steps per channel and painted as flat
  pieces in their average colour; slivers along softened edges take the
  nearest large piece's colour.
- Poster Edges: channels posterized to Posterization + 2 levels, black
  lines on the edges of any channel and of the outline, widened by Edge
  Thickness and darkened by Edge Intensity.
- Neon Glow: dimmed greys screened with a glow colour from the light
  areas (positive Glow Size) or the dark ones (negative), as strong as
  Glow Brightness.

All keep coverage and work in document units at any Document Raster
Effects resolution. Photoshop filters 33/57; cargo xtask ci passes all
9 steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:17:54 +07:00
Janiz-zzzandClaude Opus 5.5 838d81157e i18n(fi): review the machine-drafted Finnish rows
Native review of the rows added in the Finnish merge: "artboard" is
piirtoalusta everywhere else in the catalog, so the two export rows that
said piirtoalue now match; "Color Scheme" becomes Väriteema so it no
longer reads the same as Swatches (Värimallit). The other added rows
read correctly and are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 16:17:53 +07:00
BFlat 009384b2e7 Merge pull request #2666 from storytold/pr/batch-1017b-i18n-docs
i18n and docs: rows and ROADMAP entry for the fifth 2026-10-17 batch
2026-10-17 15:55:00 +07:00
BFlat 8776f6c896 Merge pull request #2577 from dennismutuku2005/fix/print-settings-windows-2569
Print: open printing preferences for the selected printer on Windows …
2026-10-17 15:54:20 +07:00
BFlat 150467c7f9 Merge pull request #2542 from danngreen/feat/move-remembers-last
Move dialog opens remembers the last drag or move values
2026-10-17 15:54:06 +07:00
BFlat 08afdd101d Merge pull request #2406 from dotCooCoo/feat/graph-column-designs
Graphs: Object › Graph › Column draws columns with graph designs
2026-10-17 15:53:52 +07:00
BFlat 93cdb4c4e3 Merge pull request #2424 from dotCooCoo/feat/live-paint-gap-options
Live Paint: Gap Options find and close gaps in Live Paint groups
2026-10-17 15:53:37 +07:00
BFlat c01eeff7ed Merge pull request #2437 from dotCooCoo/feat/svg-interactivity
Window: the SVG Interactivity panel attaches JavaScript events and files for SVG export
2026-10-17 15:53:20 +07:00
BFlat a329e499ce Merge pull request #2496 from dotCooCoo/feat/new-artboard-options
Artboards: Alt-click on New Artboard opens Artboard Options for the new artboard
2026-10-17 15:49:21 +07:00
BFlat 2217ad5009 Merge pull request #2556 from dotCooCoo/feat/contextual-task-bar-artboard
Contextual Task Bar: the Artboard tool offers Resize, Duplicate, Rearrange All and Export
2026-10-17 15:48:57 +07:00
BFlat 9745847617 Merge pull request #2559 from dotCooCoo/feat/control-bar-artboard-options
Control bar: the Artboard tool shows the active artboard's preset, name, X, Y, W and H
2026-10-17 15:48:38 +07:00
BFlat 403bc4a804 Merge pull request #2563 from dotCooCoo/feat/optical-margin-alignment
Type: Optical Margin Alignment hangs punctuation and letter edges outside the frame
2026-10-17 15:48:24 +07:00
BFlat 7b28595c11 Merge pull request #2616 from dotCooCoo/feat/expand-pattern-fills
Expand: pattern fills expand into their tiles clipped by the shape
2026-10-17 15:48:09 +07:00
BFlat 0e18cf2a08 Merge pull request #2619 from dotCooCoo/fix/save-added-extension-asks-before-replacing
Save and Export ask before replacing a file that an added extension names
2026-10-17 15:47:52 +07:00
BFlat e91140e743 Merge pull request #2536 from japbcoelho/fix/document-profiles
PDF: colours are written in the profiles assigned to the document
2026-10-17 15:46:54 +07:00
BFlat bbd015651d Merge pull request #2469 from dotCooCoo/feat/touch-type-tool
Type: the Touch Type tool moves, scales and rotates single characters
2026-10-17 15:46:41 +07:00
BFlat b11c9a9c57 Merge pull request #2524 from dotCooCoo/feat/place-rtf
Place: RTF files place as area type with their formatting
2026-10-17 15:46:26 +07:00
BFlat 2f01a1df92 Merge pull request #2562 from dotCooCoo/feat/brushes-multiselect
Brushes: Shift-click and Ctrl/Cmd-click select several brushes, and Delete Brush deletes them
2026-10-17 15:46:12 +07:00
BFlat 0332936393 Merge pull request #2573 from dotCooCoo/feat/blend-panel
Blend: Window › Blend opens a Blend panel with the blend's spacing, orientation and commands
2026-10-17 15:46:01 +07:00
BFlat 06ad0becf1 Merge pull request #2624 from dotCooCoo/fix/blend-spine-messages-translated
i18n: blend spine errors are translated in every complete language
2026-10-17 15:45:46 +07:00
dotCooCoo 800db5b29d i18n: Dutch, Danish and Indonesian rows for the Rasterize effect's resolution option
main now lists nl, da and id as complete-menu catalogs, so the dialog's
Use Document Raster Effects Resolution label needs a row in each.
2026-10-17 01:39:03 -07:00
dotCooCoo 84ccee71ef Merge main into feat/live-rasterize-effect 2026-10-17 01:25:27 -07:00
dotCooCoo fa46998006 Effects: Effect › Rasterize draws an object as pixels while its paths stay editable
The Effect menu had no Rasterize. Object › Rasterize replaces the
object with an image, so its paths can no longer be edited.

Effect › Rasterize… (`rasterize`) is a live effect with Object ›
Rasterize's options: color model, resolution (the document's raster
effects resolution by default), white or transparent background,
anti-aliasing, Create Clipping Mask and Add Around Object. The canvas
draws the object as square pixels over its bounds, and its paths, type
and other art stay as they are. The effects above it in the stack,
shadows included, are part of its image, and shadows below it are cast
by the image. When Rasterize is an object's only raster effect, Expand
Appearance and PDF and SVG export give the image Object › Rasterize
gives with the same options.

Object › Rasterize now measures the art as drawn, with its geometry
effects, brush art and raster effects, so a Transform effect that moves
the art no longer leaves the image empty. Its Create Clipping Mask
clips the image to the art as drawn. The canvas no longer panics
drawing a pixel effect such as Gaussian Blur on an object about 65,500
screen pixels wide: the raster of such an object is at most 32,768
pixels a side, and the object is drawn into it at a lower resolution.

Docs: docs/mcp.md, docs/effects-parity.md, docs/target-app-parity.md,
ROADMAP.
2026-10-16 05:24:14 -07:00
319 changed files with 24572 additions and 2091 deletions

No files matched your search

Generated
+3
View File
@@ -4617,6 +4617,7 @@ dependencies = [
name = "vectorcraft-doc"
version = "0.12.0"
dependencies = [
"hayro-ccitt",
"image",
"jpeg-encoder",
"kurbo 0.13.1",
@@ -4656,6 +4657,7 @@ dependencies = [
"kurbo 0.13.1",
"log",
"proptest",
"roxmltree 0.21.1",
"ruzstd",
"serde",
"serde_json",
@@ -4883,6 +4885,7 @@ dependencies = [
name = "vectorcraft-testkit"
version = "0.12.0"
dependencies = [
"fax",
"image",
"kurbo 0.13.1",
"proptest",
+5 -3
View File
@@ -194,8 +194,10 @@ drag-and-drop, set **Preferences › Performance › Linux Display Server** to *
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's
pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse: set the same
preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use XWayland only when a pen is
attached as VectorCraft starts, and restart; or start the app with `WAYLAND_DISPLAY= vectorcraft` (see
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
### Use it from Claude Code and other agents
@@ -245,7 +247,7 @@ The scores are self-assessed from our code against Illustrator's public document
- reliable exchange of Illustrator's own `.ai` files (layer structure in real files; Illustrator's editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- about half of the Photoshop-style raster effects (27 of 57 still missing; Film Grain is available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
- about half of the Photoshop-style raster effects (24 of 57 still missing; Cutout, Film Grain, Neon Glow and Poster Edges are available under Artistic, Halftone Pattern under Sketch) and the Effect Gallery;
- 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.
+9 -5
View File
@@ -9,7 +9,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (about 115 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (about 40 community PRs of 2026-10-18: Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -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 44/50, Photoshop effects 30/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 33/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% | 23–39 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 49% | 21–35 |
| 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: 28 filters and the Effect Gallery, 23–39 h.
5. **Raster effects** (G2) alongside: 24 filters and the Effect Gallery, 21–35 h.
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
## Progress log
@@ -134,7 +134,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-17 | About 115 community PRs landed in five batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). |
| 2026-10-18 | About 40 community PRs landed in one batch through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
@@ -179,6 +180,9 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
| 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged |
| 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged |
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
| 2026-10-14 | minor | Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. |
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
+3 -2
View File
@@ -159,11 +159,12 @@ fn main() -> ExitCode {
}
}
/// A headless session that remembers what the user keeps between runs: the PDF presets saved and
/// the ICC profiles loaded (#2168, #2174), in the folders next to the preferences that the desktop
/// A headless session that remembers what the user keeps between runs: the PDF presets saved, the
/// ICC profiles loaded and the plug-ins installed (#2168, #2174, #2541), in the folders next to the preferences that the desktop
/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there.
fn with_stores(mut h: Headless) -> Headless {
prefs_dir::attach_profile_store(&mut h.session);
prefs_dir::attach_plugin_store(&mut h.session);
prefs_dir::attach_pdf_preset_store(&mut h.session);
h
}
+4 -1
View File
@@ -383,7 +383,10 @@ mod tests {
});
let mut reader = BufReader::new(client.try_clone().unwrap());
for id in [1, 2] {
writeln!(client, "{}", json!({"id": id, "method": "inspect"})).unwrap();
// The client sends each line in one write. `writeln!` on the socket sends the JSON in many
// small writes, and when the machine is busy, a pause of more than the 120 ms line deadline
// between two of them closes the connection.
client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
+11 -3
View File
@@ -788,14 +788,19 @@ fn main() -> std::process::ExitCode {
let custom_titlebar = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
// and X11 (XWayland) When a Pen Is Attached does so when a pen is attached, where the pen draws (#2028).
#[cfg(target_os = "linux")]
let options = {
let mut options = options;
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
if xwayland::x11_libraries_found() {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
if pref == Some("x11WithPen") {
log::info!("pen found: opening the window through XWayland (Preferences › Performance › Linux Display Server)");
} else {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
}
use winit::platform::x11::EventLoopBuilderExtX11 as _;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_x11();
@@ -858,6 +863,7 @@ fn main() -> std::process::ExitCode {
let mut session = Session::new();
// The profiles loaded before come back before the preferences name any of them.
prefs_dir::attach_profile_store(&mut session);
prefs_dir::attach_plugin_store(&mut session);
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
load_prefs(&mut app, saved);
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
@@ -874,6 +880,8 @@ fn main() -> std::process::ExitCode {
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
// With the User Defined libraries listed, the persistent library panels reopen.
vectorcraft_ui_egui::panels::library_panel::restore(&mut app);
// So do the Libraries panel's libraries; runs without preferences (agents' test
// runs) keep theirs for the session only, never touching the user's.
if prefs_enabled() {
+8
View File
@@ -41,6 +41,14 @@ pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) {
}
}
/// Install the plug-ins Install Plug-in kept in the `Plug-ins` folder (#2541), and keep the ones
/// installed from now on there.
pub fn attach_plugin_store(session: &mut vectorcraft_engine::Session) {
if let Some(dir) = store_dir("Plug-ins") {
session.attach_plugin_store(dir);
}
}
/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from
/// now on there. After the preferences were restored: the folder is the one place the presets live.
pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) {
+129 -17
View File
@@ -1,11 +1,15 @@
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
//! never keeps the app from starting.
//! #1151). X11 (XWayland) When a Pen Is Attached does so only when a pen is attached as the app
//! starts: winit 0.30 doesn't bind the Wayland tablet protocol, and XWayland hands the pen to X11
//! windows as a pointer (#2028). Only when XWayland can run the window: an X display is set and
//! the X11 libraries winit loads at runtime are installed. Otherwise the window opens on Wayland,
//! so a missing library never keeps the app from starting.
//!
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories. The pen
//! check comes from PhotoCraft's `tablet.rs`: the input devices the kernel lists in
//! `/proc/bus/input/devices`.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
@@ -16,12 +20,50 @@ use std::path::{Path, PathBuf};
const X11_LIBS: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
/// and an X display to open.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, or
/// `x11WithPen` chosen and `pen` finding a pen; a Wayland session (winit picks Wayland whenever
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only for
/// `x11WithPen`, after the session checks.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>, pen: impl FnOnce() -> bool) -> bool {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
let session = (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY");
match pref {
Some("x11") => session,
Some("x11WithPen") => session && pen(),
_ => false,
}
}
/// `BTN_TOOL_PEN` (linux/input-event-codes.h): the key that a pen tablet's stylus, a pen display's
/// pen or a pen-enabled touchscreen's pen reports.
const BTN_TOOL_PEN: u32 = 0x140;
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
const ABS_X: u32 = 0x00;
const ABS_Y: u32 = 0x01;
/// Is a pen attached? Reads `/proc/bus/input/devices`; a file that can't be read has no pen.
pub fn pen_attached() -> bool {
std::fs::read_to_string("/proc/bus/input/devices").is_ok_and(|devices| has_pen(&devices))
}
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` and whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`, as
/// libinput asks of a tablet tool. A virtual keyboard made with uinput can claim every key code,
/// `BTN_TOOL_PEN` included, but has no absolute axes.
fn has_pen(devices: &str) -> bool {
devices.split("\n\n").any(|device| {
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN))
&& bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
})
}
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
/// significant first. Short or malformed bitmaps have no bits set.
fn bit_set(bitmap: &str, bit: u32) -> bool {
let bits = usize::BITS;
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
}
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
@@ -98,18 +140,88 @@ mod tests {
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
}
fn no_probe() -> bool {
panic!("the pen check ran")
}
#[test]
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11"), &wayland));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
assert!(!wanted(Some("automatic"), &wayland));
assert!(!wanted(None, &wayland));
assert!(!wanted(Some("wayland"), &wayland));
assert!(wanted(Some("x11"), &wayland, no_probe));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("automatic"), &wayland, no_probe));
assert!(!wanted(None, &wayland, no_probe));
assert!(!wanted(Some("wayland"), &wayland, no_probe));
// An X11 session already opens on X11; without an X display there is no XWayland to use.
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
}
/// X11 (XWayland) When a Pen Is Attached (#2028): XWayland on a Wayland session with an X
/// display when a pen is found, the session's own display server otherwise. The pen check
/// runs only on such a session.
#[test]
fn the_pen_choice_opens_xwayland_only_with_a_pen() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11WithPen"), &wayland, || true));
assert!(wanted(Some("x11WithPen"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true));
assert!(!wanted(Some("x11WithPen"), &wayland, || false));
assert!(!wanted(Some("x11WithPen"), env(&[("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
}
/// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the
/// pen's `KEY=1c03 0 0 0 0 0` has bit 0x140, `BTN_TOOL_PEN`), from PhotoCraft's tests.
const WITH_PEN: &str = "I: Bus=0011 Vendor=0001 Product=0001 Version=ab83\n\
N: Name=\"AT Translated Set 2 keyboard\"\n\
H: Handlers=sysrq kbd event3 leds\n\
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe\n\
\n\
I: Bus=0018 Vendor=06cb Product=ce26 Version=0100\n\
N: Name=\"SYNA2B33:00 06CB:CE26 Touchpad\"\n\
H: Handlers=mouse1 event7\n\
B: KEY=e520 10000 0 0 0 0\n\
B: ABS=2e0800000000003\n\
\n\
I: Bus=0003 Vendor=056a Product=0357 Version=0110\n\
N: Name=\"Wacom Intuos Pro M Pen\"\n\
H: Handlers=mouse2 event10\n\
B: KEY=1c03 0 0 0 0 0\n\
B: ABS=1000f000003\n";
/// A uinput virtual keyboard that claims every key code, `BTN_TOOL_PEN` included, and has no
/// absolute axes (`EV=20013`, no `B: ABS=` line), from PhotoCraft's tests.
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
N: Name=\"Virtual Keyboard\"\n\
H: Handlers=sysrq rfkill kbd event256 leds \n\
B: EV=20013\n\
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n";
#[test]
fn a_pen_is_a_device_with_btn_tool_pen_and_absolute_axes() {
assert!(has_pen(WITH_PEN));
let without = WITH_PEN.split("\n\n").take(2).collect::<Vec<_>>().join("\n\n");
assert!(!has_pen(&without), "a touchpad's BTN_TOOL_FINGER (0x145) is not a pen");
// A pen display reports the pen with its own axes.
assert!(has_pen("N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n"));
// Axes without ABS_X or ABS_Y, or no axes at all, are no pen.
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
assert!(!has_pen(&pen_without_axes));
// The virtual keyboard claims the key but is no pen, and a real pen beside it is still found.
assert!(VIRTUAL_KEYBOARD.lines().find_map(|l| l.strip_prefix("B: KEY=")).is_some_and(|k| bit_set(k, BTN_TOOL_PEN)));
assert!(!has_pen(VIRTUAL_KEYBOARD));
assert!(has_pen(&format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}")));
// Short or malformed bitmaps are no pen, never a panic.
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
assert!(!has_pen(&format!("{keys}\nB: ABS=3")), "{keys:?}");
}
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
}
}
#[test]
+3
View File
@@ -126,3 +126,6 @@ sync_github_name = true
[people.dennismutuku2005]
sync_github_name = true
[people.makesomethingshit]
sync_github_name = true
+2
View File
@@ -14,6 +14,8 @@ vectorcraft-color = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
tiff = { workspace = true }
# 1-bit TIFFs compressed with CCITT Group 3 (Compression 2 and 3).
hayro-ccitt = { workspace = true }
zune-jpeg = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+217
View File
@@ -201,6 +201,53 @@ impl ImageBlob {
let bytes = self.bytes.as_slice();
if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }
}
/// The ink amounts around pixel (`x`, `y`) of a CMYK image, in the pixel space of an image
/// object of `object` (width, height) pixels: the average of the `size` × `size` pixels centered
/// on it, clipped to the image, as C, M, Y and K bytes. `None` outside the image and for images
/// [`Self::cmyk`] doesn't read. The inks of the last CMYK image are kept for the next sample
/// (the Eyedropper samples one image click after click), as [`Self::sample_object`] keeps its
/// pixels.
pub fn sample_cmyk_object(&self, x: f64, y: f64, object: (u32, u32), size: u32) -> Option<[u8; 4]> {
thread_local! {
static LAST: std::cell::RefCell<Option<(String, Arc<Inks>)>> = const { std::cell::RefCell::new(None) };
}
if !self.is_cmyk() {
return None;
}
let key = self.content_key();
let inks = LAST.with_borrow_mut(|last| match last {
Some((k, inks)) if *k == key => Some(inks.clone()),
_ => {
let inks = Arc::new(self.cmyk()?);
*last = Some((key, inks.clone()));
Some(inks)
}
})?;
let k = |n: u32, of: u32| n as f64 / of.max(1) as f64;
sample_inks(&inks, x * k(inks.width, object.0), y * k(inks.height, object.1), size)
}
}
/// [`ImageBlob::sample_cmyk_object`] of `inks` at (`x`, `y`) in their own pixel space.
fn sample_inks(inks: &Inks, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
let (w, h) = (i64::from(inks.width), i64::from(inks.height));
let (cx, cy) = (x.floor() as i64, y.floor() as i64);
if !(0..w).contains(&cx) || !(0..h).contains(&cy) {
return None;
}
let r = i64::from(size.max(1)) / 2;
let (mut sum, mut n) = ([0u64; 4], 0u64);
for py in (cy - r).max(0)..=(cy + r).min(h - 1) {
for px in (cx - r).max(0)..=(cx + r).min(w - 1) {
let i = usize::try_from((py * w + px) * 4).ok()?;
let p = inks.data.get(i..i + 4)?;
sum.iter_mut().zip(p).for_each(|(s, v)| *s += u64::from(*v));
n += 1;
}
}
let c = |v: u64| ((v + n / 2) / n) as u8;
Some(sum.map(c))
}
/// The ink amounts of a four-component JPEG (its samples inverted back; YCCK converted to CMYK
@@ -302,6 +349,162 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
image::RgbaImage::from_raw(w, h, out)
}
/// Why a TIFF compressed with CCITT Group 3 wasn't read ([`group3_tiff_rgba`]).
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum Group3Error {
/// Its pixels would take more than [`crate::MAX_RASTER_ALLOC`] as RGBA.
#[error("an image of {w} × {h} pixels is too large to draw")]
TooLarge { w: u32, h: u32 },
/// A strip or tile ends before its last row or can't be decoded (it holds a code the decoder
/// doesn't read, such as T.4's uncompressed mode, or a row of the wrong width), or a tile is
/// larger than [`crate::MAX_RASTER_ALLOC`] allows as RGBA.
#[error("could not decode image: {0}")]
Damaged(hayro_ccitt::DecodeError),
}
/// The pixels of a 1-bit TIFF compressed with CCITT Group 3, which the image decoder doesn't read,
/// as RGBA. Compression 2 is modified Huffman coding with each row starting at a byte and no EOL
/// codes. Compression 3 is ITU-T T.4 coding with an EOL code before each row: one-dimensional rows,
/// or with T4Options bit 0, rows that a tag bit marks as one-dimensional or as coded against the
/// row above. Fill bits before an EOL are skipped. Rows in T.4's uncompressed mode (T4Options
/// bit 1) can't be decoded. Each strip or tile is coded on its own. White runs are 0 bits and black
/// runs are 1 bits, as libtiff writes them, and PhotometricInterpretation 0 shows 0 bits as white
/// and 1 shows them as black. FillOrder 2 (each byte's lowest bit first) is read too. Together, the
/// strips and tiles are read from at most as many bytes as the file has. `None` for other images;
/// an error when the pixels would take more than [`crate::MAX_RASTER_ALLOC`] as RGBA, a tile is
/// larger than that allowance, or a strip or tile can't be decoded or ends before its last row.
pub fn group3_tiff_rgba(bytes: &[u8]) -> Option<Result<image::RgbaImage, Group3Error>> {
use hayro_ccitt::{DecodeError, DecodeSettings, DecoderContext, EncodingMode};
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
let compression = d.find_tag_unsigned::<u16>(Tag::Compression).ok().flatten()?;
let (w, h) = d.dimensions().ok()?;
if kind != tiff::ColorType::Gray(1) || !matches!(compression, 2 | 3) || w == 0 || h == 0 {
return None;
}
// The level of a 0 bit (a white run): white when white is zero (0), black when black is zero (1).
let zero = match d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten() {
Some(0) => u8::MAX,
Some(1) => 0,
_ => return None,
};
if u64::from(w) * u64::from(h) > crate::MAX_RASTER_ALLOC / 4 {
return Some(Err(Group3Error::TooLarge { w, h }));
}
let t4_options = d.find_tag_unsigned::<u32>(Tag::Unknown(292)).ok().flatten().unwrap_or(0);
let lsb_first = d.find_tag_unsigned::<u16>(Tag::FillOrder).ok().flatten() == Some(2);
let encoding = if compression == 3 && t4_options & 1 != 0 {
// Each row's tag bit says how it is coded.
EncodingMode::Group3_2D { k: 2 }
} else {
EncodingMode::Group3_1D
};
// The width and height of each strip or tile, and the tags of their offsets and byte counts.
let tiled = d.get_chunk_type() == tiff::decoder::ChunkType::Tile;
let (cw, ch, offsets, counts) = if tiled {
let tw = d.find_tag_unsigned::<u32>(Tag::TileWidth).ok().flatten().filter(|&v| v > 0)?;
let th = d.find_tag_unsigned::<u32>(Tag::TileLength).ok().flatten().filter(|&v| v > 0)?;
(tw, th, Tag::TileOffsets, Tag::TileByteCounts)
} else {
let rows = d.find_tag_unsigned::<u32>(Tag::RowsPerStrip).ok().flatten().filter(|&v| v > 0).map_or(h, |r| r.min(h));
(w, rows, Tag::StripOffsets, Tag::StripByteCounts)
};
let damaged = |e: DecodeError| Some(Err(Group3Error::Damaged(e)));
// A tile is held to the allowance the image is held to; a strip is no larger than the image.
if u64::from(cw) * u64::from(ch) > crate::MAX_RASTER_ALLOC / 4 {
return damaged(DecodeError::Overflow);
}
let offsets = d.find_tag_unsigned_vec::<u64>(offsets).ok().flatten().unwrap_or_default();
let counts = d.find_tag_unsigned_vec::<u64>(counts).ok().flatten().unwrap_or_default();
let across = w.div_ceil(cw);
let chunks = u64::from(across) * u64::from(h.div_ceil(ch));
if chunks > offsets.len().min(counts.len()) as u64 {
return damaged(DecodeError::UnexpectedEof);
}
let mut gray = vec![zero; usize::try_from(u64::from(w) * u64::from(h)).ok()?];
let mut budget = bytes.len();
for (i, (&offset, &count)) in (0..chunks).zip(offsets.iter().zip(&counts)) {
let (x0, y0) = (u32::try_from(i % u64::from(across)).ok()? * cw, u32::try_from(i / u64::from(across)).ok()? * ch);
let Some(start) = usize::try_from(offset).ok().filter(|&s| s <= bytes.len()) else {
return damaged(DecodeError::UnexpectedEof);
};
let len = usize::try_from(count).unwrap_or(usize::MAX).min(bytes.len() - start).min(budget);
budget -= len;
let data = bytes.get(start..start + len)?;
let data: std::borrow::Cow<[u8]> = if lsb_first { data.iter().map(|b| b.reverse_bits()).collect() } else { data.into() };
let settings = DecodeSettings {
columns: cw,
// A tile is coded whole; the last strip only has the image's remaining rows.
rows: if tiled { ch } else { ch.min(h - y0) },
end_of_block: compression == 3,
end_of_line: compression == 3,
rows_are_byte_aligned: compression == 2,
encoding,
invert_black: false,
};
let mut rows = Rows { out: &mut gray, width: w, height: h, x0, y0, x: 0, y: 0, zero };
if let Err(e) = hayro_ccitt::decode(&data, &mut rows, &mut DecoderContext::new(settings)) {
return damaged(e);
}
// The data ended, or six EOL codes came, before the strip's or tile's last row.
if rows.y < settings.rows {
return damaged(DecodeError::UnexpectedEof);
}
}
let gray = image::GrayImage::from_raw(w, h, gray)?;
Some(Ok(image::DynamicImage::ImageLuma8(gray).to_rgba8()))
}
/// Where a strip or tile of a Group 3 TIFF ([`group3_tiff_rgba`]) puts its decoded pixels: `out`
/// holds the image's levels row by row, `width` × `height`, and the strip or tile starts at (`x0`,
/// `y0`). Pixels past the image's right or bottom edge (a tile's padding) are dropped.
struct Rows<'a> {
out: &'a mut [u8],
width: u32,
height: u32,
x0: u32,
y0: u32,
/// The next pixel in the strip or tile.
x: u64,
y: u32,
/// The level of a white run.
zero: u8,
}
impl Rows<'_> {
/// The next `n` pixels of the row, a white run's or a black run's.
fn run(&mut self, white: bool, n: u64) {
let level = if white { self.zero } else { u8::MAX - self.zero };
let row = u64::from(self.y0) + u64::from(self.y);
let left = u64::from(self.x0) + self.x;
let right = left.saturating_add(n).min(u64::from(self.width));
if row < u64::from(self.height) && left < right {
let at = row * u64::from(self.width);
if let (Ok(a), Ok(b)) = (usize::try_from(at + left), usize::try_from(at + right))
&& let Some(px) = self.out.get_mut(a..b)
{
px.fill(level);
}
}
self.x = self.x.saturating_add(n);
}
}
impl hayro_ccitt::Decoder for Rows<'_> {
fn push_pixel(&mut self, white: bool) {
self.run(white, 1);
}
fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) {
self.run(white, u64::from(chunk_count) * 8);
}
fn next_line(&mut self) {
self.y = self.y.saturating_add(1);
self.x = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -430,6 +633,20 @@ mod tests {
assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]);
}
#[test]
fn a_cmyk_image_samples_its_ink_amounts() {
let b = ImageBlob::cmyk_tiff(&inks()).unwrap();
assert_eq!(b.sample_cmyk_object(0.5, 0.5, (3, 2), 1), Some([0, 40, 100, 0]));
// An object of 6 × 4 pixels showing these 3 × 2: (5, 3) is the last pixel.
assert_eq!(b.sample_cmyk_object(5.0, 3.0, (6, 4), 1), Some([12, 34, 56, 78]));
// 3 × 3 around (1, 0), clipped to the image: all six pixels averaged.
assert_eq!(b.sample_cmyk_object(1.5, 0.5, (3, 2), 3), Some([45, 55, 69, 56]));
assert_eq!(b.sample_cmyk_object(3.5, 0.5, (3, 2), 1), None, "outside");
let mut png = Vec::new();
image::RgbaImage::new(2, 2).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert_eq!(ImageBlob::png(png).sample_cmyk_object(0.5, 0.5, (2, 2), 1), None, "an RGB image has no inks");
}
#[test]
fn a_cmyk_jpeg_reads_back_as_ink_amounts() {
// A flat CMYK JPEG: Adobe's inverted samples with its marker.
+234 -25
View File
@@ -294,19 +294,24 @@ fn edits_contents(n: &Node) -> bool {
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
/// glyph outlines need the font engine, above this crate.
fn clip_contains(clip: &Node, p: Point) -> bool {
clip_regions(clip).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
}
/// The regions `clip` clips to ([`Node::clip_shapes`]), text counted by its frame as in
/// [`clip_contains`].
fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)> {
let frame = |n: &Node| {
let b = n.geometric_bounds()?;
Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default()))
};
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
clip.clip_shapes(Some(&frame))
}
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
/// isn't shown, or when `p` is outside its reach.
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
if shaper_hides(parent, i, opt.scope) {
return false;
}
// Read-only samples can read locked/template art; hidden art never participates.
@@ -315,10 +320,22 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
}
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c)
|| c.extras().shaper.is_some() && source_isolated(c)
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
}
/// Is the first child of `n` the isolated container `scope`?
fn source_of(n: &Node, scope: Option<NodeId>) -> bool {
n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == scope)
}
/// Do clicks and marquees skip the child `i` of `parent` with `scope` isolated? For a Shaper
/// Group that is its original shapes (its first child), or, in Construction mode, when they are
/// isolated, the art the group shows.
fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
}
fn hit_children(
parent: &Node,
p: Point,
@@ -464,15 +481,39 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option
}
}
/// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is
/// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an
/// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`.
pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
let NodeKind::Compound { children, rule } = &n.kind else { return None };
let parts: Vec<_> = children.iter().rev().filter_map(|c| c.path_data().map(|pd| (c.id, pd.to_bezpath(), pd.bounds()))).collect();
if let Some((id, ..)) = parts.iter().find(|(_, bp, _)| stroke_contains(bp, 0.0, tol, p)) {
return Some(*id);
}
// The innermost by the size of its box.
let size = |b: &Option<Rect>| b.map_or(f64::INFINITY, |b| b.area());
parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id)
}
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
/// instances are measured by their art ([`Node::geometric_bounds_with`]).
/// instances are measured by their art ([`Node::geometric_bounds_with`]). The art of a clip group
/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping
/// path clips to, and then only within that region's bounds.
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
Some(s) => match s.children() {
Some(c) => c.iter().collect(),
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
Some(s) => match (s.children(), marquee_in(s, r)) {
(Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
(Some(_), None) => vec![],
// A single object isolated: only it.
None => doc.parent_of(s.id).and_then(|p| doc.node(p)).and_then(Node::children).into_iter().flatten().filter(|c| c.id == s.id).collect(),
(None, _) => doc
.parent_of(s.id)
.and_then(|p| doc.node(p))
.and_then(|p| Some((p.children()?, marquee_in(p, r)?)))
.into_iter()
.flat_map(|(c, r)| c.iter().filter(|c| c.id == s.id).map(move |c| (c, r)))
.collect(),
},
// The objects of the layers, looking through sublayers (hidden, locked and template ones
// are left out).
@@ -480,28 +521,33 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
.layers
.iter()
.filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
.flat_map(|l| layer_objects(l))
.flat_map(|l| layer_objects(l, r))
.collect(),
};
fn layer_objects(l: &Node) -> Vec<&std::sync::Arc<Node>> {
/// The objects of layer `l`, each with the part of `r` that the clipping masks of `l` and of
/// the sublayers it is in leave.
fn layer_objects(l: &Node, r: Rect) -> Vec<(&std::sync::Arc<Node>, Rect)> {
let mut out = vec![];
let Some(r) = marquee_in(l, r) else { return out };
for c in l.children().into_iter().flatten() {
if c.is_layer() {
if c.visible && !c.locked && !c.is_template() {
out.extend(layer_objects(c));
out.extend(layer_objects(c, r));
}
} else {
out.push(c);
out.push((c, r));
}
}
out
}
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
/// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip
/// ([`shaper_hides`]).
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
children.iter().any(|c| c.visible && touches(c, s, r))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => marquee_in(n, r)
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
// A box with no width or no height (an instance of a symbol that draws a straight
// line) is touched where the marquee crosses it.
_ => n.geometric_bounds_with(s).is_some_and(|b| {
@@ -510,36 +556,67 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}),
}
}
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec<NodeId>) {
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
for c in children {
if c.visible && !c.locked {
collect_leaves(c, s, r, out);
let Some(r) = marquee_in(n, r) else { return };
for (i, c) in children.iter().enumerate() {
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
collect_leaves(c, s, r, scope, out);
}
}
}
_ => {
if touches(n, s, r) {
if touches(n, s, r, scope) {
out.push(n.id);
}
}
}
}
let symbols = doc.symbol_boxes();
for t in tops {
for (t, r) in tops {
if !t.visible || t.locked {
continue;
}
if leaves {
collect_leaves(t, &symbols, r, &mut out);
} else if touches(t, &symbols, r) {
collect_leaves(t, &symbols, r, scope, &mut out);
} else if touches(t, &symbols, r, scope) {
out.push(t.id);
}
}
out
}
/// The part of a marquee `r` in which it can touch the children of `parent`. For a clip group or a
/// layer with a clipping mask, this is what [`marquee_in_clip`] returns for its clipping path (None
/// when the marquee misses it); otherwise it is all of `r`.
fn marquee_in(parent: &Node, r: Rect) -> Option<Rect> {
match parent.children().and_then(|c| c.first()) {
Some(clip) if parent.clips() => marquee_in_clip(clip, r),
_ => Some(r),
}
}
/// The part of a marquee `r` in which it can touch the art that `clip` clips (the clipping path of
/// a clip group or of a layer with a clipping mask): `r` cut to the bounds of the regions `clip`
/// clips to ([`clip_regions`]), or `None` when `r` meets none of those regions. Within those
/// bounds, art outside a region that is not a rectangle still counts.
fn marquee_in_clip(clip: &Node, r: Rect) -> Option<Rect> {
use vectorcraft_geom::kurbo::Shape;
let regions = clip_regions(clip);
// The edge of a region is its outline with each open subpath closed, as `fill_contains` fills it.
let meets = |(bp, rule): &(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)| {
let mut edge = vectorcraft_geom::PathData::from_bezpath(bp);
edge.subpaths.iter_mut().for_each(|sp| sp.closed = true);
fill_contains(bp, *rule, r.center()) || vectorcraft_geom::hit::intersects_rect(&edge, r)
};
if !regions.iter().any(meets) {
return None;
}
let bounds = regions.iter().map(|(bp, _)| bp.bounding_box()).reduce(|a, b| a.union(b))?;
Some(r.intersect(bounds))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -613,6 +690,138 @@ mod tests {
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
}
/// The art of a 200 pt wide rectangle clipped to its left half (0..100 × 0..100): the mask and
/// the art, in that order.
fn half_clipped(d: &mut Document) -> (Node, Node) {
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Appearance::default_art());
(mask, art)
}
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
/// Group Selection's leaves and in isolation mode.
#[test]
fn a_marquee_leaves_a_clip_group_by_its_clipped_away_art() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let (g, art_id, beside) = (d.alloc_id(), art.id, d.alloc_id());
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
d.insert(Some(l), 1, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
// This marquee crosses only the hidden art's top and right edges.
let hidden = Rect::new(150.0, -10.0, 210.0, 50.0);
assert!(marquee(&d, hidden, None, false).is_empty());
assert!(marquee(&d, hidden, None, true).is_empty());
assert!(marquee(&d, hidden, Some(g), false).is_empty());
assert!(marquee(&d, hidden, Some(art_id), false).is_empty());
// This marquee surrounds the rectangle beside and crosses the hidden art's bottom edge.
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside]);
// This marquee crosses the shown half's top edge and goes on into the hidden half.
let shown = Rect::new(50.0, -10.0, 160.0, 10.0);
assert_eq!(marquee(&d, shown, None, false), vec![g]);
assert!(marquee(&d, shown, None, true).contains(&art_id));
assert_eq!(marquee(&d, shown, Some(g), false).len(), 2, "the mask and the art");
}
/// #2345: in the corner of a diamond mask's box, outside the diamond, a marquee over the
/// clipped art's corner leaves the clip group. An open mask clips to its area closed by a
/// straight line, and a marquee across that line takes the art it reaches inside.
#[test]
fn a_marquee_must_meet_the_clipping_path_itself() {
// A clip group of a mask through `pts` (closed or open) over the art `art`.
let clipped = |pts: &[(f64, f64)], closed: bool, art: Rect| {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let mut mask = vectorcraft_geom::BezPath::new();
mask.move_to(pts[0]);
for &p in &pts[1..] {
mask.line_to(p);
}
if closed {
mask.close_path();
}
let mask = Node::path(d.alloc_id(), vectorcraft_geom::PathData::from_bezpath(&mask), Appearance::default());
let art = Node::path(d.alloc_id(), shapes::rectangle(art), Appearance::default_art());
let g = d.alloc_id();
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
(d, g)
};
let (d, g) = clipped(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], true, Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(marquee(&d, Rect::new(-10.0, -10.0, 10.0, 10.0), None, false).is_empty());
assert_eq!(marquee(&d, Rect::new(-10.0, 40.0, 10.0, 60.0), None, false), vec![g], "over the diamond's left point");
// The mask is the triangle above the line from (0, 0) to (100, 100), and the art lies inside it.
let (d, g) = clipped(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false, Rect::new(55.0, 40.0, 70.0, 50.0));
assert_eq!(marquee(&d, Rect::new(40.0, 45.0, 60.0, 70.0), None, false), vec![g]);
}
/// #2345: the objects of a layer or sublayer with a clipping mask are touched only where the
/// mask shows them, as in a clip group.
#[test]
fn a_marquee_leaves_art_a_layer_clips_away() {
for sublayer in [false, true] {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let beside = d.alloc_id();
d.insert(Some(l), 0, Node::path(beside, shapes::rectangle(Rect::new(150.0, 80.0, 190.0, 130.0)), Appearance::default_art())).unwrap();
let masked = d.alloc_id();
let mut layer = Node::layer(masked, "Masked", crate::LayerColor::Preset(2));
layer.set_clips(true);
d.insert(sublayer.then_some(l), 9, layer).unwrap();
let (mask, art) = half_clipped(&mut d);
let (mask_id, art_id) = (mask.id, art.id);
d.insert(Some(masked), 0, mask).unwrap();
d.insert(Some(masked), 1, art).unwrap();
assert!(marquee(&d, Rect::new(150.0, -10.0, 210.0, 50.0), None, false).is_empty(), "sublayer: {sublayer}");
assert_eq!(marquee(&d, Rect::new(140.0, 70.0, 195.0, 140.0), None, false), vec![beside], "sublayer: {sublayer}");
assert_eq!(marquee(&d, Rect::new(50.0, -10.0, 160.0, 10.0), None, false), vec![mask_id, art_id], "sublayer: {sublayer}");
}
}
/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over
/// the original shape of a face the group erased it selects nothing, with Group Selection's
/// leaves too, and over a face it shows it takes the group. With the original shapes isolated
/// (Construction mode) it takes the original shape it reaches.
#[test]
fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// Two original squares, 0..100 and 50..150; the group shows only the first one's face.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
let b_id = b.id;
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
sources.name = Some(crate::shaper::SOURCES.into());
sources.opacity = 0.0;
let sources_id = sources.id;
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
let face_id = face.id;
let g = d.alloc_id();
let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]);
group.extras_mut().shaper = Some(Box::default());
d.insert(Some(l), 0, group).unwrap();
// This marquee crosses only the right edge of the second square, whose face is erased.
let erased = Rect::new(140.0, 40.0, 160.0, 60.0);
assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses");
assert!(marquee(&d, erased, None, false).is_empty());
assert!(marquee(&d, erased, None, true).is_empty());
assert!(marquee(&d, erased, Some(g), false).is_empty());
// This marquee crosses the shown face's left edge.
let shown = Rect::new(-10.0, 40.0, 10.0, 60.0);
assert_eq!(marquee(&d, shown, None, false), vec![g]);
assert_eq!(marquee(&d, shown, None, true), vec![face_id]);
assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]);
// Construction mode: the original shapes are the objects.
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
+145 -2
View File
@@ -22,10 +22,12 @@ mod inline;
pub mod intertwine;
pub mod shared;
mod symbol_export_type;
mod symbol_nine_slice;
mod symbol_registration;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice};
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
pub mod live;
@@ -594,6 +596,23 @@ pub struct ImageBlob {
pub proxy: Option<Arc<Vec<u8>>>,
}
/// What the rulers measure from (View › Rulers): the active artboard's origin, the default of a
/// new document, or one origin for the whole document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RulerMode {
#[default]
Artboard,
Global,
}
impl RulerMode {
/// The mode of a document saved before the choice existed: global, as its rulers measured.
pub fn legacy() -> Self {
Self::Global
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
pub version: u32,
@@ -607,6 +626,15 @@ pub struct Document {
/// The rulers' zero point in document coordinates. Moving it changes measurements, never art.
#[serde(default, skip_serializing_if = "skip::is_default")]
pub ruler_origin: Point,
/// View › Rulers › Change to Global / Artboard Rulers: whether the rulers and position fields
/// measure from [`Self::ruler_origin`] (global) or from the active artboard's own origin.
/// Documents saved before the choice existed measured globally and open that way.
#[serde(default = "RulerMode::legacy")]
pub rulers: RulerMode,
/// Artboard rulers' zero point of each artboard (by [`Artboard::id`]), as an offset from its
/// top-left corner; an artboard not listed measures from that corner.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub artboard_ruler_origins: BTreeMap<u32, Vec2>,
#[serde(default)]
pub color_mode: ColorMode,
pub artboards: Vec<Artboard>,
@@ -758,6 +786,14 @@ fn yes() -> bool {
true
}
/// Is point `p` on the artboard at `rect`, its edges included, within a millionth of a point? This
/// is the rule [`Document::art_on_artboard`] applies to art flush with an edge (#890), where
/// `Rect::contains` leaves out the right and bottom edges.
pub fn artboard_holds(rect: Rect, p: Point) -> bool {
let r = rect.inflate(1e-6, 1e-6);
r.x0 <= p.x && p.x <= r.x1 && r.y0 <= p.y && p.y <= r.y1
}
/// Listed nodes by parent: the parent's index path (empty for the top-level layers), then the index
/// of each listed child in it, in order, with the child's place in the list.
type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
@@ -772,6 +808,27 @@ impl Document {
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD { origin } else { Point::ZERO }
}
/// Where the rulers measure from with artboard `index` active: [`Self::ruler_origin_or_zero`]
/// with global rulers; with artboard rulers that artboard's top-left corner moved by its own
/// offset (the first artboard's when `index` is out of range, the global zero point when the
/// document has no artboard or the result is out of the supported range).
pub fn ruler_origin_for(&self, index: usize) -> Point {
if self.rulers == RulerMode::Global {
return self.ruler_origin_or_zero();
}
let Some(ab) = self.artboards.get(index).or_else(|| self.artboards.first()) else { return self.ruler_origin_or_zero() };
let corner = Point::new(ab.rect.x0, ab.rect.y0);
let offset = self.artboard_ruler_origins.get(&ab.id).copied().unwrap_or(Vec2::ZERO);
let origin = corner + offset;
if origin.is_finite() && origin.x.abs() <= MAX_COORD && origin.y.abs() <= MAX_COORD {
origin
} else if corner.is_finite() && corner.x.abs() <= MAX_COORD && corner.y.abs() <= MAX_COORD {
corner
} else {
Point::ZERO
}
}
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
pub fn new(width: f64, height: f64) -> Self {
Self::new_with_mode(width, height, ColorMode::Rgb)
@@ -786,6 +843,8 @@ impl Document {
template: false,
units: Unit::Points,
ruler_origin: Point::ZERO,
rulers: RulerMode::Artboard,
artboard_ruler_origins: BTreeMap::new(),
color_mode: mode,
artboards: vec![Artboard {
id: 1,
@@ -1305,9 +1364,24 @@ impl Document {
let symbols = self.symbol_boxes();
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols)))
}
/// Artboard index containing point `p` (topmost = last).
/// Artboard index containing point `p` (topmost = last), its edges included
/// ([`Document::artboard_holding`]).
pub fn artboard_at(&self, p: Point) -> Option<usize> {
self.artboards.iter().rposition(|a| a.rect.contains(p))
self.artboard_holding(p, true)
}
/// Index of the artboard that holds point `p`: the first (the last when `topmost`) that holds it
/// inside or on its left or top edge, else the first (the last) that holds it by
/// [`artboard_holds`]: on its right or bottom edge, or within a millionth of a point outside an
/// edge. On an edge two artboards share, the artboard to the right of it or below it holds the
/// point, except at the end of the edge that lies on that artboard's bottom or right edge, such
/// as the point where both artboards' bottom edges meet; there the first (the last) holds it.
pub fn artboard_holding(&self, p: Point, topmost: bool) -> Option<usize> {
let pick = |on: &dyn Fn(Rect) -> bool| {
let on = |i: &usize| self.artboards.get(*i).is_some_and(|a| on(a.rect));
let mut all = 0..self.artboards.len();
if topmost { all.rfind(on) } else { all.find(on) }
};
pick(&|r| r.contains(p)).or_else(|| pick(&|r| artboard_holds(r, p)))
}
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
@@ -1639,6 +1713,37 @@ impl Document {
self.clear_symbol_export_type(from);
self.set_symbol_export_type(to, kind);
}
/// Symbol Options › 9-slice guides for `name`: `None` when 9-slice scaling is off (default).
pub fn symbol_nine_slice(&self, name: &str) -> Option<SymbolNineSlice> {
self.unknown.get(SYMBOL_NINE_SLICE).and_then(|m| m.get(name)).and_then(SymbolNineSlice::from_value)
}
/// Turn 9-slice scaling on with `guides`, or off with `None`, for `name`.
pub fn set_symbol_nine_slice(&mut self, name: &str, guides: Option<SymbolNineSlice>) {
let Some(g) = guides else {
let Some(o) = self.unknown.get_mut(SYMBOL_NINE_SLICE).and_then(|m| m.as_object_mut()) else { return };
o.remove(name);
if o.is_empty() {
self.unknown.remove(SYMBOL_NINE_SLICE);
}
return;
};
let m = self.unknown.entry(SYMBOL_NINE_SLICE.to_string()).or_insert_with(|| serde_json::json!({}));
if let Some(o) = m.as_object_mut() {
o.insert(name.into(), g.to_value());
}
}
/// Move the stored 9-slice guides when a symbol is renamed.
pub fn rename_symbol_nine_slice(&mut self, from: &str, to: &str) {
if from == to {
return;
}
let g = self.symbol_nine_slice(from);
self.set_symbol_nine_slice(from, None);
self.set_symbol_nine_slice(to, g);
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> {
@@ -1757,6 +1862,44 @@ mod tests {
assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]);
}
#[test]
fn artboard_at_counts_the_right_and_bottom_edges() {
// Artboard 0 is (0, 0) to (100, 100), artboard 1 is to its right and artboard 2 below it.
let mut d = Document::new(100.0, 100.0);
for rect in [Rect::new(100.0, 0.0, 200.0, 100.0), Rect::new(0.0, 100.0, 100.0, 200.0)] {
let mut a = d.artboards[0].clone();
a.rect = rect;
d.artboards.push(a);
}
let held = |d: &Document, x: f64, y: f64| (d.artboard_at(Point::new(x, y)), d.artboard_holding(Point::new(x, y), false));
// Each artboard's own right and bottom edges, edges with roundoff, and points beyond them.
assert_eq!(held(&d, 200.0, 50.0), (Some(1), Some(1)), "right edge");
assert_eq!(held(&d, 150.0, 100.0), (Some(1), Some(1)), "bottom edge");
assert_eq!(held(&d, 200.0, 100.0), (Some(1), Some(1)), "bottom right corner");
assert_eq!(held(&d, 100.0, 150.0), (Some(2), Some(2)), "right edge");
assert_eq!(held(&d, 50.0, 200.0 + 1e-9), (Some(2), Some(2)), "roundoff past the bottom edge");
assert_eq!(held(&d, -1e-9, 50.0), (Some(0), Some(0)), "roundoff past the left edge");
assert_eq!(held(&d, 200.001, 50.0), (None, None));
assert_eq!(held(&d, 50.0, 200.001), (None, None));
// On an edge two artboards share, the artboard to the right of it or below it holds the
// point, in either list order, except at the end of the edge that lies on that artboard's
// bottom or right edge.
assert_eq!(held(&d, 100.0, 50.0), (Some(1), Some(1)));
assert_eq!(held(&d, 50.0, 100.0), (Some(2), Some(2)));
// The bottom edges of artboards 0 and 1 meet at (100, 100), which is also artboard 2's top
// right corner: the first holds the point, or the last when `topmost`.
assert_eq!(held(&d, 100.0, 100.0), (Some(2), Some(0)), "shared corner");
d.artboards.swap(0, 1);
assert_eq!(held(&d, 100.0, 50.0), (Some(0), Some(0)));
// Where two artboards overlap, the topmost holds the point, or the first one when not
// `topmost`.
let mut inner = d.artboards[1].clone();
inner.rect = Rect::new(10.0, 10.0, 90.0, 90.0);
d.artboards.push(inner);
assert_eq!(held(&d, 50.0, 50.0), (Some(3), Some(1)));
assert_eq!(held(&d, 90.0, 50.0), (Some(1), Some(1)), "inside artboard 1, on the right edge of artboard 3");
}
#[test]
fn find_and_paths() {
let (d, a, b) = doc_with_rects();
+53
View File
@@ -79,6 +79,59 @@ impl ImageBlob {
sample_pixels(&img, x * k(img.width(), object.0), y * k(img.height(), object.1), size)
}
/// The colours of a `rows` × `cols` mesh laid over the image: `(rows + 1) * (cols + 1)` straight
/// RGBA samples, row-major, one per grid line crossing (row 0 is the top). Each is the
/// alpha-weighted average of the area around the crossing, one grid cell wide and high (a
/// quarter of each of the neighbouring cells), not a single pixel. A crossing over transparent
/// pixels gets their average colour at alpha 0. `None` when `rows` or `cols` is 0 or the
/// image can't be decoded.
pub fn grid_samples(&self, rows: u32, cols: u32) -> Option<Vec<[u8; 4]>> {
if rows == 0 || cols == 0 {
return None;
}
let img = self.decoded()?;
let (w, h) = (img.width() as usize, img.height() as usize);
if w == 0 || h == 0 {
return None;
}
// The pixel columns [from, to) around grid line `i` of `n`, at least one pixel wide.
let span = |i: u32, n: u32, len: usize| {
let at = |k: f64| (k / n as f64 * len as f64).round().clamp(0.0, len as f64) as usize;
let (from, to) = (at(i as f64 - 0.5), at(i as f64 + 0.5));
let from = from.min(len - 1);
(from, to.max(from + 1))
};
let mut out = Vec::with_capacity((rows as usize + 1) * (cols as usize + 1));
for r in 0..=rows {
let (y0, y1) = span(r, rows, h);
for c in 0..=cols {
let (x0, x1) = span(c, cols, w);
let (mut sum, mut plain, mut alpha, mut n) = ([0u64; 3], [0u64; 3], 0u64, 0u64);
for y in y0..y1 {
for x in x0..x1 {
let [pr, pg, pb, pa] = img.get_pixel(x as u32, y as u32).0;
let a = pa as u64;
for (k, v) in [pr, pg, pb].into_iter().enumerate() {
sum[k] += v as u64 * a;
plain[k] += v as u64;
}
alpha += a;
n += 1;
}
}
let n = n.max(1);
out.push(if alpha == 0 {
let m = |k: usize| ((plain[k] + n / 2) / n) as u8;
[m(0), m(1), m(2), 0]
} else {
let m = |k: usize| ((sum[k] + alpha / 2) / alpha) as u8;
[m(0), m(1), m(2), ((alpha + n / 2) / n) as u8]
});
}
}
Some(out)
}
/// The decoded pixels (the last image decoded is kept for the next sample).
fn decoded(&self) -> Option<Arc<image::RgbaImage>> {
thread_local! {
+1 -1
View File
@@ -430,7 +430,7 @@ fn art_id() -> crate::NodeId {
}
/// Visit `n` and every descendant (opacity-mask art included) mutably.
fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
pub(crate) fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
f(n);
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
each_mut(Arc::make_mut(&mut m.art), f);
+22
View File
@@ -192,6 +192,16 @@ impl Background {
/// Largest bleed on any side, in points (1 inch).
pub const MAX_BLEED: f64 = 72.0;
/// Largest superscript or subscript size, and the farthest its baseline moves either way, in
/// percent of the font size.
pub const MAX_SCRIPT: f64 = 500.0;
/// Largest synthesized small capitals size, in percent of the font size.
pub const MAX_SMALL_CAPS: f64 = 200.0;
/// Returns `bleed` with each side kept to 0–[`MAX_BLEED`] points; a side that isn't finite becomes 0.
pub fn bleed_in_range(bleed: [f64; 4]) -> [f64; 4] {
bleed.map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 })
}
/// File → Document Setup.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -274,6 +284,18 @@ impl DocSetup {
pub fn flattener(&self) -> &str {
self.flattener_preset.as_deref().unwrap_or(DEFAULT_FLATTENER)
}
/// Moves the bleed and the type proportions into the ranges Document Setup takes, each to the
/// nearer end: the bleed by [`bleed_in_range`], superscript and subscript sizes to
/// 1–[`MAX_SCRIPT`] and positions to ±[`MAX_SCRIPT`], the small caps size to
/// 1–[`MAX_SMALL_CAPS`].
pub fn keep_in_range(&mut self) {
self.bleed = bleed_in_range(self.bleed);
for m in [&mut self.superscript, &mut self.subscript] {
m.size = m.size.clamp(1.0, MAX_SCRIPT);
m.position = m.position.clamp(-MAX_SCRIPT, MAX_SCRIPT);
}
self.small_caps_size = self.small_caps_size.clamp(1.0, MAX_SMALL_CAPS);
}
}
#[cfg(test)]
+57
View File
@@ -0,0 +1,57 @@
//! Symbol Options › Enable Guides for 9-Slice Scaling: four guides that cut a symbol into a 3 × 3
//! grid so that, in Animate / Flash handoff, the corners keep their size while the edges and the
//! centre stretch
//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
//!
//! The guides are stored as fractions (0..1) of the symbol's natural size, measured from the left
//! and top edges and from the right and bottom edges, so they survive resizing the art. Like
//! [`crate::SymbolExportType`] this is handoff metadata: instances place the same on the canvas.
//! Stored under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry.
use serde_json::{Value, json};
/// Document unknown key: `{ symbolName: [left, top, right, bottom] }`.
pub const SYMBOL_NINE_SLICE: &str = "symbolNineSlice";
/// The four 9-slice guides as fractions of the symbol's natural size.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SymbolNineSlice {
/// Left guide, from the left edge.
pub left: f64,
/// Top guide, from the top edge.
pub top: f64,
/// Right guide, from the right edge.
pub right: f64,
/// Bottom guide, from the bottom edge.
pub bottom: f64,
}
impl Default for SymbolNineSlice {
/// The centre third of each axis is the stretchy cell.
fn default() -> Self {
let t = 1.0 / 3.0;
Self { left: t, top: t, right: t, bottom: t }
}
}
impl SymbolNineSlice {
/// Guides from `[left, top, right, bottom]`; `None` unless each is finite, within 0..1, and the
/// two on each axis leave a gap between them.
pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Option<Self> {
let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v);
(ok(left) && ok(top) && ok(right) && ok(bottom) && left + right < 1.0 && top + bottom < 1.0).then_some(Self { left, top, right, bottom })
}
pub fn to_value(self) -> Value {
json!([self.left, self.top, self.right, self.bottom])
}
pub fn from_value(v: &Value) -> Option<Self> {
let a = v.as_array()?;
let n = |i: usize| a.get(i).and_then(Value::as_f64);
if a.len() != 4 {
return None;
}
Self::new(n(0)?, n(1)?, n(2)?, n(3)?)
}
}
+201
View File
@@ -32,6 +32,11 @@ pub struct CharStyle {
/// wouldn't pick (an imported file's type matched to it); None = the one they pick.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_version: Option<String>,
/// Variation axis settings of a variable font (Character panel › Variable Font), by axis tag
/// (`wght`, `wdth`), in the axis's own units. They replace the named instance's setting of
/// each axis they name; the other axes keep the style's settings. Empty: the style as it is.
#[serde(default, rename = "fontAxes", skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub font_axes: std::collections::BTreeMap<String, f64>,
/// Size in points.
pub size: f64,
/// Leading in points; None = Auto (120% of size).
@@ -174,6 +179,18 @@ pub struct ScriptMetrics {
impl ScriptMetrics {
pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 };
/// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to
/// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside
/// its range becomes the nearer end.
pub fn in_range(self) -> Self {
Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) }
}
}
/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for
/// `smallCapsSize`. A size outside it becomes the nearer end.
fn small_caps_in_range(size: f64) -> f64 {
size.clamp(1.0, 200.0)
}
impl Default for ScriptMetrics {
@@ -210,6 +227,14 @@ impl CharPosition {
CharPosition::Subscript(_) => "subscript",
}
}
/// Returns this position with its proportions kept by [`ScriptMetrics::in_range`].
pub fn in_range(self) -> Self {
match self {
CharPosition::Normal => CharPosition::Normal,
CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()),
CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()),
}
}
}
fn ten() -> f64 {
@@ -231,6 +256,7 @@ impl Default for CharStyle {
font_family: "Source Sans 3".into(),
font_style: "Regular".into(),
font_version: None,
font_axes: std::collections::BTreeMap::new(),
size: 12.0,
leading: None,
tracking: 0.0,
@@ -267,6 +293,20 @@ impl CharStyle {
pub fn effective_leading(&self) -> f64 {
self.leading.unwrap_or(self.size * 1.2)
}
/// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size
/// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading
/// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way)
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
/// baseline shift that is not a number becomes 0.
pub fn keep_dimensions_in_range(&mut self) {
let default = Self::default();
let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or };
self.size = within(self.size, default.size);
self.h_scale = within(self.h_scale, default.h_scale);
self.v_scale = within(self.v_scale, default.v_scale);
self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD));
self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) };
}
/// Do the characters draw a stroke (a paint and a positive weight)?
pub fn has_stroke(&self) -> bool {
!self.stroke.is_none() && self.stroke_width > 0.0
@@ -327,6 +367,60 @@ impl CharStyle {
pub fn basic_appearance(&self) -> Appearance {
Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() }
}
/// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and
/// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`.
pub fn keep_scripts_in_range(&mut self) {
self.position = self.position.in_range();
self.small_caps = self.small_caps.map(small_caps_in_range);
}
}
impl crate::Document {
/// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup`
/// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
/// back (opacity-mask art included), in graphs' label styles and in the character styles. It
/// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`].
pub fn keep_scripts_in_range(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
r.style.keep_scripts_in_range();
}
}
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
let l = &mut g.label_styles;
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
st.keep_scripts_in_range();
}
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
// Character styles store the attributes they set by their serialized names.
for def in self.char_styles.iter_mut() {
if let Some(v) = def.attrs.get_mut("position")
&& let Ok(p) = serde_json::from_value::<CharPosition>(v.clone())
&& p.in_range() != p
{
*v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null);
}
if let Some(v) = def.attrs.get_mut("small_caps")
&& let Some(size) = v.as_f64()
&& small_caps_in_range(size) != size
{
*v = small_caps_in_range(size).into();
}
}
}
}
/// Tab stop alignment (Tabs panel).
@@ -452,6 +546,26 @@ pub struct ParaStyle {
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
/// line.
pub kinsoku: Kinsoku,
/// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
pub roman_hanging: bool,
}
impl ParaStyle {
/// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to
/// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range
/// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is
/// not a number becomes 0.
pub fn keep_spacing_in_range(&mut self) {
let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) };
for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] {
*v = within(*v, -crate::MAX_COORD, crate::MAX_COORD);
}
for t in &mut self.tabs {
t.position = within(t.position, 0.0, 100_000.0);
}
}
}
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
@@ -493,6 +607,8 @@ struct ParaStyleFile {
burasagari: Burasagari,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kinsoku: Kinsoku,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
roman_hanging: bool,
}
impl From<ParaStyle> for ParaStyleFile {
@@ -519,6 +635,7 @@ impl From<ParaStyle> for ParaStyleFile {
leading_model,
burasagari,
kinsoku,
roman_hanging,
..
} = p;
Self {
@@ -539,6 +656,7 @@ impl From<ParaStyle> for ParaStyleFile {
leading_model,
burasagari,
kinsoku,
roman_hanging,
}
}
}
@@ -562,6 +680,7 @@ impl From<ParaStyleFile> for ParaStyle {
leading_model,
burasagari,
kinsoku,
roman_hanging,
..
} = f;
Self {
@@ -581,6 +700,7 @@ impl From<ParaStyleFile> for ParaStyle {
leading_model,
burasagari,
kinsoku,
roman_hanging,
}
}
}
@@ -1355,6 +1475,87 @@ impl TextObject {
});
self.cached_baselines.iter().copied().chain(first)
}
/// Keeps every run's size, leading, baseline shift and scales in range
/// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab
/// stops ([`ParaStyle::keep_spacing_in_range`]).
pub fn keep_type_values_in_range(&mut self) {
for r in &mut self.runs {
r.style.keep_dimensions_in_range();
}
for p in self.para_styles_mut() {
p.keep_spacing_in_range();
}
}
}
impl crate::Document {
/// Keeps the character dimensions, indents, paragraph spacing and tab stops in range
/// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
/// back (opacity-mask art included), and the same attributes of the character and paragraph
/// styles. It recurses through the art: call it on a document no deeper than
/// [`crate::depth::MAX_DEPTH`].
pub fn keep_type_values_in_range(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
t.keep_type_values_in_range();
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
// Styles store the attributes they set by their serialized names (a number, or null for
// Auto leading); a number is rewritten only when it changes.
for def in self.char_styles.iter_mut() {
let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64);
let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() };
st.size = num("size").unwrap_or(st.size);
st.h_scale = num("h_scale").unwrap_or(st.h_scale);
st.v_scale = num("v_scale").unwrap_or(st.v_scale);
st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift);
st.keep_dimensions_in_range();
let kept = [
("size", Some(st.size)),
("h_scale", Some(st.h_scale)),
("v_scale", Some(st.v_scale)),
("baseline_shift", Some(st.baseline_shift)),
("leading", st.leading),
];
for (k, v) in kept {
if let Some(old) = def.attrs.get_mut(k)
&& old.is_number()
&& old.as_f64() != v
{
*old = v.into();
}
}
}
for def in self.para_styles.iter_mut() {
for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] {
if let Some(v) = def.attrs.get_mut(k)
&& let Some(x) = v.as_f64()
&& x.abs() > crate::MAX_COORD
{
*v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into();
}
}
for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() {
if let Some(v) = t.get_mut("position")
&& let Some(x) = v.as_f64()
&& !(0.0..=100_000.0).contains(&x)
{
*v = x.clamp(0.0, 100_000.0).into();
}
}
}
}
}
#[cfg(test)]
+16 -6
View File
@@ -116,6 +116,10 @@ pub struct Variables {
pub bindings: BTreeMap<NodeId, BTreeSet<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_dataset: Option<String>,
/// Lock Variables: while set, variables can't be defined, deleted or renamed. Objects can
/// still be bound to them and unbound, and datasets still change and apply.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub locked: bool,
}
impl Variables {
@@ -184,20 +188,26 @@ impl Document {
self.variables.bindings = bindings;
}
/// Whether a variable may read and rewrite the object `id`: it is in the document, neither
/// it nor anything above it is locked, and nothing above it is hidden. Its own visibility
/// doesn't count, as that is what a visibility variable drives: a row that hid it must be
/// able to show it again, and a capture must record it hidden.
/// Whether a variable may be bound to the object `id`: it is in the document, neither it nor
/// anything above it is locked, and nothing above it is hidden. Its own visibility doesn't
/// count, as that is what a visibility variable drives.
pub fn variable_target(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
}
/// Whether a data set reads and rewrites the bound object `id`: it is in the document and
/// neither it nor anything above it is locked. Hidden objects count, and so do objects in a
/// hidden layer or group.
pub fn dataset_target(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked)))
}
/// What the object `id` holds for the variable of `kind`: its characters, whether it shows,
/// or a linked image's path. `None` when the object is gone, is not of that kind, has no
/// link path yet (an embedded image), or is locked or on a hidden or locked layer (the
/// link path yet (an embedded image), or is locked or in a locked layer or group (the
/// document's value is whatever it was left at).
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
if !self.variable_target(id) {
if !self.dataset_target(id) {
return None;
}
let node = self.node(id)?;
+41 -11
View File
@@ -5,7 +5,7 @@
//!
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, and the
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, Rasterize, and the
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
//! Unsharp Mask and Glowing Edges) are described by
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
@@ -37,6 +37,7 @@ mod bake;
mod clip;
mod distort;
mod group;
mod lighting;
mod live;
mod marks;
pub mod pixel;
@@ -68,7 +69,7 @@ pub use group::{
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RasterFx, outset, raster_effects};
pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
@@ -150,6 +151,7 @@ fn lengths_of(id: &str) -> Lengths {
"texture.patchwork" => always(&["squareSize"]),
"texture.stainedGlass" => always(&["cellSize"]),
"sketch.halftonePattern" => always(&["size"]),
RASTERIZE => always(&["addAround"]),
_ => Lengths::default(),
}
}
@@ -202,29 +204,29 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
REVOLVE,
"Revolve…",
&["Effect", "3D and Materials"],
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
),
g(
EXTRUDE,
"Extrude & Bevel…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
INFLATE,
"Inflate…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
ROTATE,
"Rotate…",
&["Effect", "3D and Materials"],
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
"convertToShape.rectangle",
@@ -336,6 +338,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
),
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
r(
"artistic.cutout",
"Cutout…",
ARTISTIC,
"{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}),
),
r(
"artistic.filmGrain",
"Film Grain…",
@@ -343,6 +352,20 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.",
json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}),
),
r(
"artistic.neonGlow",
"Neon Glow…",
ARTISTIC,
"{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.posterEdges",
"Poster Edges…",
ARTISTIC,
"{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(
"blur.radial",
"Radial Blur…",
@@ -614,6 +637,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it",
json!({}),
));
v.push(r(
RASTERIZE,
"Rasterize…",
&["Effect"],
"{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes",
json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}),
));
v
}
@@ -763,7 +793,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) {
/// Is `id` a raster (painted) effect?
pub fn is_raster(id: &str) -> bool {
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE)
|| PIXEL_EFFECTS.contains(&id)
}
+373
View File
@@ -0,0 +1,373 @@
//! Shared conversion and vector paint shading for the 3D effects' directional lights.
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, FillLayer};
fn parse(v: &Value) -> Option<Color> {
let s = v.as_str()?;
// Match the command's opaque RGB contract, before any parsing or allocation.
(s.len() == 7 && s.starts_with('#') && s.as_bytes().iter().skip(1).all(u8::is_ascii_hexdigit)).then(|| Color::from_hex(s)).flatten()
}
pub(crate) fn light_color(p: &Value) -> [f64; 3] {
p.get("lightColor").and_then(parse).unwrap_or(Color::WHITE).to_rgb_uncalibrated().map(f64::from)
}
pub(crate) fn validate(p: &Value) -> Result<(), String> {
if p.get("lightColor").is_some_and(|v| parse(v).is_none()) {
return Err("lightColor must be an opaque RGB color in #rrggbb form".into());
}
if let Some(list) = p.get("lights").filter(|v| !v.is_null()) {
parse_lights(list)?;
}
Ok(())
}
pub(crate) fn lights(p: &Value) -> Option<vectorcraft_three_d::Lights> {
p.get("lights").filter(|v| !v.is_null()).and_then(|v| parse_lights(v).ok())
}
fn parse_lights(v: &Value) -> Result<vectorcraft_three_d::Lights, String> {
use vectorcraft_three_d::{Light, Lights, MAX_LIGHTS};
let list = v.as_array().filter(|v| !v.is_empty() && v.len() <= MAX_LIGHTS).ok_or("lights must contain one to ten lights")?;
let mut out = Vec::with_capacity(list.len());
for item in list {
let item = item.as_object().ok_or("each light must be an object")?;
if item.keys().any(|key| !["color", "intensity", "rotation", "height", "softness", "behind"].contains(&key.as_str())) {
return Err("unknown light setting: use color, intensity, rotation, height, softness or behind".into());
}
let defaults = Light::default();
let number = |key: &str, default: f64, min: f64, max: f64| -> Result<f64, String> {
match item.get(key) {
None => Ok(default),
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && *n >= min && *n <= max)
.ok_or_else(|| format!("light {key} must be a number from {min} to {max}")),
}
};
let color = match item.get("color") {
None => [1.0; 3],
Some(v) => parse(v).ok_or("light color must use opaque #rrggbb")?.to_rgb_uncalibrated().map(f64::from),
};
let behind = item.get("behind").map_or(Ok(false), |v| v.as_bool().ok_or("light behind must be true or false"))?;
out.push(Light {
color,
behind,
intensity: number("intensity", defaults.intensity * 100.0, 0.0, 100.0)? / 100.0,
rotation: number("rotation", defaults.rotation, -180.0, 180.0)?,
height: number("height", defaults.height, 0.0, 90.0)?,
softness: number("softness", defaults.softness * 100.0, 0.0, 100.0)? / 100.0,
});
}
Lights::new(&out)
}
pub(crate) fn shade(c: Color, rgb: [f64; 3]) -> Color {
let [r, g, b] = c.to_rgb();
Color::rgb(r * rgb[0] as f32, g * rgb[1] as f32, b * rgb[2] as f32)
}
pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint {
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect();
let mut p = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(p))
}
/// Multiply two independently placed vector gradients before applying the face's coverage
/// once. Separately antialiasing the source and light along each triangle edge leaves seams.
pub(crate) fn gradient_art(
id: vectorcraft_doc::NodeId,
path: &vectorcraft_geom::PathData,
paint: &Paint,
light: vectorcraft_three_d::LightGradient,
) -> vectorcraft_doc::Node {
composed_art(id, path, paint, Ramp::Single(light), 0.75)
}
pub(crate) fn rgb_gradient_art(
id: vectorcraft_doc::NodeId,
path: &vectorcraft_geom::PathData,
paint: &Paint,
light: vectorcraft_three_d::RgbGradient,
) -> vectorcraft_doc::Node {
composed_art(id, path, paint, Ramp::Rgb(light), 0.75)
}
/// Smooth Inflate triangles keep the seam within their narrow projected footprint.
pub(crate) fn facet_art(
id: vectorcraft_doc::NodeId,
path: &vectorcraft_geom::PathData,
paint: &Paint,
light: vectorcraft_three_d::RgbGradient,
seam: f64,
) -> vectorcraft_doc::Node {
let [r, g, b] = light.channels;
if r == g && g == b {
let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] };
if let Some(color) = paint.color() {
let paint = smooth_paint(light, color);
let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam);
edge.join = vectorcraft_doc::LineJoin::Round;
return vectorcraft_doc::Node::path(
id,
path.clone(),
vectorcraft_doc::Appearance {
items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)],
..Default::default()
},
);
}
return composed_art(id, path, paint, Ramp::Single(light), seam);
}
composed_art(id, path, paint, Ramp::Rgb(light), seam)
}
/// Composite smooth solid-fill facets with one clip and three channel groups per surface.
/// Black channel backdrops keep shared edges opaque before the final coverage is applied.
pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node {
use kurbo::Shape;
use std::sync::Arc;
use vectorcraft_doc::color::BlendMode;
use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, PathData, Rect, shapes};
let mut boundary = BezPath::new();
let mut bounds = None;
for face in facets {
let path = face.path();
if let Some(b) = path.bounds() {
bounds = Some(bounds.map_or(b, |r: Rect| r.union(b)));
}
let mut contour = path.to_bezpath();
if contour.area() < 0.0 {
contour = contour.reverse_subpaths();
}
boundary.extend(contour.iter());
}
let clip = Node::path(id, PathData::from_bezpath(&boundary), Appearance::default());
let bounds = bounds.unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
let channels = (0..3)
.map(|channel| {
let mut color = [0.0; 3];
if let Some(value) = color.get_mut(channel) {
*value = 1.0;
}
let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))];
children.extend(facets.iter().map(|face| {
let paint = if let Some(ramp) = face.rgb_gradient {
smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE)
} else {
Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel])))
};
let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
edge.join = vectorcraft_doc::LineJoin::Round;
Arc::new(Node::path(
id,
face.path(),
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() },
))
}));
let mut group = Node::group(id, children);
group.isolate = true;
group.blend = BlendMode::Screen;
Arc::new(group)
})
.collect();
let mut light = Node::group(id, channels);
light.isolate = true;
light.blend = BlendMode::Multiply;
let mut colors = Node::group(id, vec![Arc::new(cover(paint.clone())), Arc::new(light)]);
colors.isolate = true;
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true })
}
enum Ramp {
Single(vectorcraft_three_d::LightGradient),
Rgb(vectorcraft_three_d::RgbGradient),
}
/// Separate a gradient's color and coverage so a tessellated surface can apply its opacity
/// once, instead of building opacity at the overlapping seam strokes of adjacent faces.
pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option<Paint>) {
let mut base = paint.clone();
let mut mask = paint.clone();
let mut transparent = false;
if let (Paint::Gradient(b), Paint::Gradient(m)) = (&mut base, &mut mask) {
b.swatch = None;
m.swatch = None;
for (b, m) in b.gradient.stops.iter_mut().zip(&mut m.gradient.stops) {
transparent |= b.opacity != 1.0;
b.opacity = 1.0;
m.color = Color::WHITE;
m.swatch = None;
m.tint = 1.0;
}
if let (Some(b), Some(m)) = (&mut b.freeform, &mut m.freeform) {
for (b, m) in b.points.iter_mut().zip(&mut m.points) {
transparent |= b.opacity != 1.0;
b.opacity = 1.0;
m.color = Color::WHITE;
}
}
}
(base, transparent.then_some(mask))
}
fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node {
use kurbo::Shape;
use std::sync::Arc;
use vectorcraft_doc::color::BlendMode;
use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask};
use vectorcraft_geom::{Rect, shapes};
let (base, mask) = opaque_paint(paint);
// Clip once to the union of this single-contour face and its round seam stroke.
// Align their winding so the nonzero fill adds coverage instead of cancelling it.
let mut boundary = path.to_bezpath();
let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05);
if boundary.area() * footprint.area() < 0.0 {
boundary = boundary.reverse_subpaths();
}
footprint.extend(boundary.iter());
let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default());
// Opaque rectangles cover the complete seam stroke, without separately antialiasing
// either ramp. Opaque sources need only vector clipping, not full-frame opacity masks.
let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
let mut illumination = match light {
Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)),
Ramp::Rgb(light) => {
let channels = light
.channels
.iter()
.enumerate()
.map(|(channel, gradient)| {
let mut color = [0.0; 3];
if let Some(value) = color.get_mut(channel) {
*value = 1.0;
}
let mut layer = cover(smooth_paint(vectorcraft_three_d::LightGradient { diffuse: *gradient, ambient: 0.0, color }, Color::WHITE));
// Screen adds disjoint R, G and B channels exactly; no light is clamped
// separately. Each ramp already holds the sum of all directional lights.
layer.blend = BlendMode::Screen;
Arc::new(layer)
})
.collect();
let mut group = Node::group(id, channels);
group.isolate = true;
group
}
};
illumination.blend = BlendMode::Multiply;
let mut colors = Node::group(id, vec![Arc::new(cover(base)), Arc::new(illumination)]);
colors.isolate = true;
// Keep isolation inside the clip: the clip then applies to the finished colors,
// rather than separately antialiasing each of the two gradient drawings.
let mut face = Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true });
if let Some(mask) = mask {
face.extras_mut().mask = Some(Box::new(OpacityMask::new(cover(mask), true)));
}
face
}
pub(crate) fn fills(paint: &Paint, rgb: [f64; 3], brightness: f64, colored: bool) -> Vec<AppearanceItem> {
let shade = |c: Color| shade(c, rgb);
if let Some(c) = paint.color() {
return vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(shade(c))))];
}
// Keep the white-light gradient treatment identical to existing documents. Colored light
// shades the ramp itself, keeping placement, stops, midpoint and opacity as vector data.
if matches!(paint, Paint::Gradient(_)) && colored {
let mut shaded = paint.clone();
vectorcraft_doc::recolor::map_paint(&mut shaded, &shade);
if let Paint::Gradient(g) = &mut shaded {
g.swatch = None;
}
return vec![AppearanceItem::Fill(FillLayer::new(shaded))];
}
// Pattern tiles remain referenced artwork. Retain their existing neutral shadow
// approximation; tinting arbitrary pattern contents needs document-level evaluation.
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
shadow.opacity = (1.0 - brightness) as f32;
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Fill(shadow)]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
use vectorcraft_geom::Point;
#[test]
fn command_contract_is_strict_and_loaded_invalid_colors_fall_back_to_white() {
assert_eq!(light_color(&json!({})), [1.0; 3]);
assert_eq!(light_color(&json!({"lightColor":"#FF0000"})), [1.0, 0.0, 0.0]);
for v in [json!("#ff0"), json!("#ffffff80"), json!("blue"), json!("#gg0000"), json!(null), json!([1, 0, 0])] {
let p = json!({"lightColor":v});
assert!(validate(&p).is_err(), "{p}");
assert_eq!(light_color(&p), [1.0; 3]);
}
}
#[test]
fn malformed_loaded_lists_keep_the_legacy_light_and_valid_lists_override_it() {
for value in [json!([]), json!(vec![json!({}); 11]), json!([false]), json!([{"height": 91}]), json!([{"color":"#ff0"}])] {
let p = json!({"lights": value, "lightColor":"#ff0000"});
assert!(lights(&p).is_none());
assert_eq!(light_color(&p), [1.0, 0.0, 0.0]);
assert!(validate(&p).is_err());
}
let p = json!({"lights":[{}, {"color":"#0000ff","intensity":40}], "lightColor":"#ff0000"});
let list = lights(&p).unwrap();
assert_eq!(list.iter().count(), 2);
assert_eq!(*list.iter().next().unwrap(), vectorcraft_three_d::Light::default());
assert_eq!(list.iter().nth(1).unwrap().color, [0.0, 0.0, 1.0]);
}
#[test]
fn colored_gradient_keeps_placement_alpha_and_midpoints() {
let mut stop = GradientStop::new(0.0, Color::WHITE);
stop.opacity = 0.4;
stop.midpoint = 0.3;
let g = GradientPaint {
gradient: Gradient { kind: GradientKind::Linear, stops: vec![stop, GradientStop::new(1.0, Color::BLACK)] },
geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }),
angle: 0.0,
swatch: None,
freeform: None,
};
let paint = Paint::Gradient(Box::new(g.clone()));
let items = fills(&paint, [1.0, 0.25, 0.25], 1.0, true);
let Paint::Gradient(shaded) = items[0].paint() else { panic!("vector gradient") };
assert_eq!(shaded.geom, g.geom);
assert_eq!(shaded.gradient.stops[0].color.to_rgb(), [1.0, 0.25, 0.25]);
assert_eq!(shaded.gradient.stops[0].opacity, 0.4);
assert_eq!(shaded.gradient.stops[0].midpoint, 0.3);
assert_eq!(shaded.gradient.stops[1].color, Color::BLACK);
assert_eq!(paint, Paint::Gradient(Box::new(g)));
let white = fills(&paint, [0.4; 3], 0.4, false);
assert_eq!(white[0].paint(), &paint, "white retains the previous gradient treatment");
assert_eq!(white.len(), 2);
let unlit = fills(&paint, [0.25; 3], 0.25, true);
assert_eq!(unlit.len(), 1, "neutral ambient under colored light still tints once");
let mut freeform = if let Paint::Gradient(g) = paint { g } else { panic!("gradient") };
freeform.gradient.kind = GradientKind::Freeform;
freeform.gradient.stops = vec![GradientStop::new(0.0, Color::BLACK)];
freeform.swatch = Some("Original ramp".into());
freeform.freeform = Some(vectorcraft_doc::color::Freeform {
points: vec![vectorcraft_doc::color::FreeformPoint::new(Point::new(12.0, 18.0), Color::WHITE)],
..Default::default()
});
let original = Paint::Gradient(freeform);
let items = fills(&original, [1.0, 0.25, 0.25], 1.0, true);
let Paint::Gradient(shaded) = items[0].paint() else { panic!("freeform gradient") };
let point = shaded.freeform.as_ref().unwrap().points[0];
assert_eq!(point.at, Point::new(12.0, 18.0));
assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]);
assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch");
}
}
+136 -2
View File
@@ -1,11 +1,13 @@
//! Effect › Artistic. Original filters built from public behaviour documentation:
//! Effect › Artistic (Cutout, Film Grain, Neon Glow, 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>.
//! These are visual approximations, not reproductions of a proprietary algorithm.
use super::PixelSpace;
use super::edges::sobel;
use super::pixelate::{Place, cell, rand};
use super::texture::{luma, premultiply, straight};
use super::{PixelSpace, blur_plane};
use vectorcraft_geom::Point;
const SALT_FILM: u64 = 256;
@@ -134,3 +136,135 @@ pub(super) fn film_grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSp
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// The coverage-weighted Gaussian softening of the planes `planes` (premultiplied by `alpha`) by
/// `sigma` pixels: each divided by the softened coverage, so transparency doesn't darken edges.
fn soften(planes: &mut [Vec<f32>], alpha: &[f32], w: usize, h: usize, sigma: f64) {
let mut a = alpha.to_vec();
blur_plane(&mut a, w, h, sigma);
for plane in planes {
for (v, c) in plane.iter_mut().zip(alpha) {
*v *= c;
}
blur_plane(plane, w, h, sigma);
for (v, c) in plane.iter_mut().zip(&a) {
*v = if *c > 1e-4 { (*v / c).clamp(0.0, 1.0) } else { 0.0 };
}
}
}
/// The pixels' straight colour channels and their coverage (0..1), as planes.
fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
let colours = px.iter().map(|p| straight(*p)).collect::<Vec<_>>();
(std::array::from_fn(|k| colours.iter().map(|c| c[k]).collect()), px.iter().map(|p| f32::from(p[3]) / 255.0).collect())
}
/// Cutout: the object as flat pieces of coloured paper. Its colours, softened as `simplicity`
/// (0..10) asks and less as `fidelity` (1..3) keeps detail, fall into `levels` (2..8) steps per
/// colour channel; each step's piece is painted in the average colour of what falls into it, so
/// colours keep their hue. Slivers (the blends along softened edges) take the nearest large
/// piece's colour, so the pieces meet with crisp edges. Coverage is kept.
pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, levels: f64, simplicity: f64, fidelity: f64) {
if !([levels, simplicity, fidelity].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let levels = levels.round().clamp(2.0, 8.0) as usize;
// Simpler edges come from softer colours; more fidelity softens them less.
let sigma = 0.6 * simplicity.clamp(0.0, 10.0) * (4.0 - fidelity.clamp(1.0, 3.0)) / 3.0 / space.px;
let (mut colour, alpha) = planes(px);
soften(&mut colour, &alpha, w, h, sigma);
let at = |k: usize| [0, 1, 2].map(|i| colour[i].get(k).copied().unwrap_or(0.0));
let piece = |c: [f32; 3]| c.iter().fold(0, |n, v| n * levels + ((v * levels as f32) as usize).min(levels - 1));
// Each piece's colour: the coverage-weighted mean of the softened colours in it.
let mut sums = vec![[0.0f64; 4]; levels * levels * levels];
for (k, a) in alpha.iter().enumerate() {
let c = at(k);
let Some(sum) = sums.get_mut(piece(c)) else { continue };
for (s, v) in sum.iter_mut().zip(c) {
*s += f64::from(v * a);
}
sum[3] += f64::from(*a);
}
let mean = |sum: &[f64; 4]| [0, 1, 2].map(|i| (sum[i] / sum[3]) as f32);
let total: f64 = sums.iter().map(|s| s[3]).sum();
let heaviest = sums.iter().map(|s| s[3]).fold(0.0, f64::max);
// Pieces of at least 1% of the object (and always the largest one) are kept.
let large: Vec<[f32; 3]> = sums.iter().filter(|s| s[3] > 0.0 && (s[3] >= 0.01 * total || s[3] >= heaviest)).map(mean).collect();
let paint: Vec<Option<[f32; 3]>> = sums
.iter()
.map(|s| {
let c = (s[3] > 0.0).then(|| mean(s))?;
let far = |o: &[f32; 3]| o.iter().zip(c).map(|(a, b)| (a - b) * (a - b)).sum::<f32>();
large.iter().min_by(|a, b| far(a).total_cmp(&far(b))).copied()
})
.collect();
for (k, p) in px.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
if let Some(Some(c)) = paint.get(piece(at(k))) {
*p = premultiply(*c, f32::from(p[3]) / 255.0);
}
}
}
/// Poster Edges: the object's colours posterized to `posterization` + 2 (2..8) levels per
/// channel, with black lines drawn on the edges of any of its colour channels and of its outline. `thickness` (0..10) widens the lines and
/// `intensity` (0..10) darkens them and lets fainter edges take a line. Coverage is kept.
pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, intensity: f64, posterization: f64) {
if !([thickness, intensity, posterization].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let steps = (posterization.round().clamp(0.0, 6.0) + 1.0) as f32;
// The edges of each premultiplied channel (so the object's outline too), a step of 1 reading 1.
let mut edges = vec![0.0f32; px.len()];
for k in 0..4 {
let plane: Vec<f32> = px.iter().map(|p| f32::from(p[k]) / 255.0).collect();
for (e, s) in edges.iter_mut().zip(sobel(&plane, w, h)) {
*e = e.max(s / 4.0);
}
}
// Wider lines spread the edges; scale them back up so a spread line stays as dark.
let sigma = (0.3 + 0.25 * thickness.clamp(0.0, 10.0)) / space.px;
blur_plane(&mut edges, w, h, sigma);
let gain = ((0.3 + 0.25 * intensity.clamp(0.0, 10.0)) * (1.0 + 1.5 * sigma.max(0.0))) as f32;
for (p, e) in px.iter_mut().zip(edges) {
if p[3] == 0 {
continue;
}
let t = ((e * gain - 0.25) / 0.5).clamp(0.0, 1.0);
let ink = t * t * (3.0 - 2.0 * t);
let c = straight(*p).map(|v| (v * steps).round() / steps * (1.0 - ink));
*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)
}
/// Neon Glow: the object in dimmed greys, lit by `colour` glowing from its light areas (`size`
/// above 0) or from its dark areas (below 0), spread as far as |`size`| (0..24) says and as
/// strong as `brightness` (0..50). Coverage is kept.
pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, brightness: f64, colour: [f32; 3]) {
if !([size, brightness].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let strength = (brightness / 50.0).clamp(0.0, 1.0) as f32;
let tones: Vec<f32> = px.iter().map(|p| luma(straight(*p))).collect();
let alpha: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
let mut glow = [tones.iter().map(|l| if size < 0.0 { 1.0 - l } else { *l }).collect::<Vec<_>>()];
soften(&mut glow, &alpha, w, h, glow_spread(size) / space.px);
let [glow] = glow;
for ((p, tone), g) in px.iter_mut().zip(tones).zip(glow) {
if p[3] == 0 {
continue;
}
// The greys dim as the glow brightens, and the glow is screened over them.
let base = tone * (1.0 - 0.6 * strength);
let lit = (3.0 * g * strength).clamp(0.0, 1.0);
let c = colour.map(|v| 1.0 - (1.0 - base) * (1.0 - v.clamp(0.0, 1.0) * lit));
*p = premultiply(c, f32::from(p[3]) / 255.0);
}
}
+44 -2
View File
@@ -1,4 +1,4 @@
//! Photoshop-style raster effects (Effect › Artistic › Film Grain, Blur › Radial Blur and Smart Blur, Brush Strokes ›
//! 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 ›
@@ -39,8 +39,11 @@ 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; 29] = [
pub const PIXEL_EFFECTS: [&str; 32] = [
"artistic.cutout",
"artistic.filmGrain",
"artistic.neonGlow",
"artistic.posterEdges",
"blur.radial",
"blur.smart",
"brushStrokes.accentedEdges",
@@ -75,9 +78,19 @@ pub const PIXEL_EFFECTS: [&str; 29] = [
/// are in document units.
#[derive(Clone, Debug, PartialEq)]
pub enum PixelFx {
/// Artistic › Cutout: flat pieces of colour in `levels` (2..8) steps per channel, their
/// edges as simple as `simplicity` (0..10) and as faithful as `fidelity` (1..3) say.
Cutout { levels: f64, simplicity: f64, fidelity: f64 },
/// 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 › 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 › 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 },
/// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or
/// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples.
RadialBlur { amount: f64, zoom: bool, passes: u32 },
@@ -209,6 +222,21 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v)
};
Some(match id {
"artistic.cutout" => PixelFx::Cutout {
levels: num(p, "levels", 4.0).round().clamp(2.0, 8.0),
simplicity: num(p, "edgeSimplicity", 4.0).clamp(0.0, 10.0),
fidelity: num(p, "edgeFidelity", 2.0).round().clamp(1.0, 3.0),
},
"artistic.neonGlow" => PixelFx::NeonGlow {
size: num(p, "glowSize", 5.0).clamp(-24.0, 24.0),
brightness: num(p, "glowBrightness", 15.0).clamp(0.0, 50.0),
colour: crate::raster::color_param(p, vectorcraft_doc::color::Color::rgb(0.3, 0.65, 1.0)).to_rgb(),
},
"artistic.posterEdges" => PixelFx::PosterEdges {
thickness: num(p, "edgeThickness", 2.0).clamp(0.0, 10.0),
intensity: num(p, "edgeIntensity", 1.0).clamp(0.0, 10.0),
posterization: num(p, "posterization", 2.0).round().clamp(0.0, 6.0),
},
"artistic.filmGrain" => PixelFx::FilmGrain {
grain: num(p, "grain", 4.0).clamp(0.0, 20.0),
highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0),
@@ -367,6 +395,9 @@ impl PixelFx {
PixelFx::UnsharpMask { .. } | PixelFx::HalftonePattern { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::FilmGrain { .. }
| PixelFx::Cutout { .. }
| PixelFx::NeonGlow { .. }
| PixelFx::PosterEdges { .. }
| PixelFx::ColorHalftone { .. }
| PixelFx::Mezzotint { .. }
| PixelFx::Craquelure { .. }
@@ -409,6 +440,12 @@ impl PixelFx {
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),
// 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)),
// 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.
PixelFx::Pointillize { cell } => Some(2.5 * cell),
// The softened tones around the pixel.
@@ -429,7 +466,12 @@ impl PixelFx {
let Some(px) = pixels(data, w, h) else { return };
let to_px = |len: f64| len / space.px.max(1e-9);
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::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)
}
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => {
blur::smart(px, w, h, to_px(radius), threshold, samples)
+196
View File
@@ -2093,3 +2093,199 @@ fn halftone_draws_in_black_and_white_only() {
}
}
}
/// 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()
}
/// A left-to-right rainbow ramp, half covered at its bottom rows.
fn ramp(w: usize, h: usize) -> Vec<u8> {
image(w, h, |x, y| {
let t = x * 255 / (w - 1);
let a = if y + 4 >= h { 128 } else { 255 };
let c = [t, 255 - t, (t * 3) % 256].map(|v| (v * a / 255) as u8);
[c[0], c[1], c[2], a as u8]
})
}
#[test]
fn artistic_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("artistic.cutout", json!({})), PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 });
assert_eq!(
fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})),
PixelFx::Cutout { levels: 8.0, simplicity: 0.0, fidelity: 2.0 }
);
assert_eq!(fx("artistic.posterEdges", json!({})), PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 });
assert_eq!(
fx("artistic.posterEdges", json!({"edgeThickness": 99, "edgeIntensity": -1, "posterization": 6.4})),
PixelFx::PosterEdges { thickness: 10.0, intensity: 0.0, posterization: 6.0 }
);
let PixelFx::NeonGlow { size, brightness, colour } = fx("artistic.neonGlow", json!({})) else { panic!() };
assert_eq!((size, brightness), (5.0, 15.0));
assert!((colour[0] - 0.3).abs() < 0.01 && (colour[1] - 0.65).abs() < 0.01 && colour[2] == 1.0, "{colour:?}");
assert_eq!(
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(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape");
}
}
#[test]
fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
let (w, h) = (16, 12);
let src = ramp(w, h);
let run = |f: &PixelFx, s: &PixelSpace| {
let mut out = src.clone();
f.apply(&mut out, w, h, s);
out
};
let colour = [0.3, 0.65, 1.0];
let filters = [
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 },
PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour },
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 },
];
for f in &filters {
assert_ne!(run(f, &space(w, h)), src, "{f:?} changes the art in a valid space");
}
let mut changed = Vec::new();
for f in &filters {
for px in [0.0, -1.0, f64::NAN, f64::INFINITY] {
if run(f, &PixelSpace { px, ..space(w, h) }) != src {
changed.push(format!("{f:?} at px {px}"));
}
}
}
for f in [
PixelFx::Cutout { levels: f64::NAN, simplicity: 4.0, fidelity: 2.0 },
PixelFx::Cutout { levels: 4.0, simplicity: f64::INFINITY, fidelity: 2.0 },
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: f64::NEG_INFINITY },
PixelFx::NeonGlow { size: f64::NAN, brightness: 15.0, colour },
PixelFx::NeonGlow { size: 5.0, brightness: f64::INFINITY, colour },
PixelFx::PosterEdges { thickness: f64::NAN, intensity: 1.0, posterization: 2.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: f64::INFINITY, posterization: 2.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: f64::NEG_INFINITY },
] {
if run(&f, &space(w, h)) != src {
changed.push(format!("{f:?}"));
}
}
assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}");
}
#[test]
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
let (w, h) = (64, 24);
let src = ramp(w, h);
for levels in [2, 3, 5, 8] {
let mut out = src.clone();
fx("artistic.cutout", json!({"levels": levels})).apply(&mut out, w, h, &space(w, h));
let opaque: std::collections::BTreeSet<_> = colours(&out).into_iter().filter(|p| p[3] == 255).collect();
assert!(opaque.len() <= levels * levels * levels && opaque.len() >= 2, "{levels} levels: {} colours", opaque.len());
assert!(opaque.len() < 64, "flat pieces, not a ramp: {levels} levels, {} colours", opaque.len());
assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3]));
}
}
#[test]
fn cutout_simplicity_smooths_the_pieces_edges() {
// A noisy grey: simpler edges join the speckles into fewer changes between neighbours.
let (w, h) = (48, 48);
let src = image(w, h, |x, y| grey(if (x * 7 + y * 13) % 5 < 2 { 60 } else { 190 }));
let changes = |simplicity: f64| {
let mut out = src.clone();
fx("artistic.cutout", json!({"levels": 2, "edgeSimplicity": simplicity, "edgeFidelity": 1})).apply(&mut out, w, h, &space(w, h));
out.as_chunks::<4>().0.windows(2).filter(|p| p[0] != p[1]).count()
};
assert!(changes(10.0) * 4 < changes(0.0), "{} vs {}", changes(10.0), changes(0.0));
}
#[test]
fn poster_edges_draw_black_lines_on_edges_and_posterize_the_rest() {
let (w, h) = (40, 20);
// Two flat halves: a line where they meet, posterized colour away from it.
let src = image(w, h, |x, _| if x < w / 2 { [200, 90, 30, 255] } else { [30, 120, 210, 255] });
let mut out = src.clone();
fx("artistic.posterEdges", json!({"edgeThickness": 2, "edgeIntensity": 10, "posterization": 0})).apply(&mut out, w, h, &space(w, h));
let line = at(&out, w, w / 2, h / 2);
assert!(line[0] < 40 && line[1] < 40 && line[2] < 40, "a dark line on the edge: {line:?}");
// 2 levels per channel: every far pixel is 0 or 255 in each channel.
for x in [3, w - 4] {
let p = at(&out, w, x, h / 2);
assert!(p[..3].iter().all(|v| *v == 0 || *v == 255), "{x}: {p:?}");
}
// Inside a flat colour, away from the object's outline, there are no lines: it only posterizes.
let mut flat = image(w, h, |_, _| [200, 90, 30, 255]);
fx("artistic.posterEdges", json!({"edgeIntensity": 10, "posterization": 6})).apply(&mut flat, w, h, &space(w, h));
let inside: std::collections::BTreeSet<_> = (6..h - 6).flat_map(|y| (6..w - 6).map(move |x| (x, y))).map(|(x, y)| at(&flat, w, x, y)).collect();
assert_eq!(inside.len(), 1, "{inside:?}");
assert!(at(&flat, w, 0, h / 2)[0] < 60, "the outline takes a line");
}
#[test]
fn poster_edges_thickness_widens_the_lines() {
let (w, h) = (60, 10);
let src = image(w, h, |x, _| grey(if x < w / 2 { 30 } else { 230 }));
let dark = |thickness: f64| {
let mut out = src.clone();
fx("artistic.posterEdges", json!({"edgeThickness": thickness, "edgeIntensity": 10, "posterization": 6})).apply(&mut out, w, h, &space(w, h));
(0..w).filter(|x| at(&out, w, *x, h / 2)[0] < 20 && (*x >= w / 2 || at(&src, w, *x, h / 2)[0] > 20)).count()
};
assert!(dark(10.0) > dark(0.0), "{} vs {}", dark(10.0), dark(0.0));
}
#[test]
fn neon_glow_lights_the_bright_or_the_dark_areas_in_its_colour() {
let (w, h) = (40, 20);
let src = image(w, h, |x, _| grey(if x < w / 2 { 20 } else { 235 }));
let run = |size: f64| {
let mut out = src.clone();
fx("artistic.neonGlow", json!({"glowSize": size, "glowBrightness": 40, "color": "#ff0000"})).apply(&mut out, w, h, &space(w, h));
(at(&out, w, 3, h / 2), at(&out, w, w - 4, h / 2))
};
// Red glow: the red channel rises above green and blue where it glows.
let red = |p: [u8; 4]| i32::from(p[0]) - i32::from(p[1]);
let (dark, light) = run(8.0);
assert!(red(light) > 60 && red(light) > red(dark) + 40, "light areas glow: {dark:?} {light:?}");
let (dark, light) = run(-8.0);
assert!(red(dark) > 60 && red(dark) > red(light) + 40, "dark areas glow: {dark:?} {light:?}");
// No brightness: dimmed greys only.
let mut out = src.clone();
fx("artistic.neonGlow", json!({"glowBrightness": 0})).apply(&mut out, w, h, &space(w, h));
assert!(out.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2] && p[3] == 255));
}
#[test]
fn artistic_filters_look_the_same_at_any_resolution() {
// The same art at 1 and at 2 pixels per point: the filter at the coarse resolution matches
// the fine result sampled at the same points.
let (w, h) = (48, 32);
let art = |x: f64, y: f64| grey(if (x - 24.0).hypot(y - 16.0) < 10.0 { 230 } else { 40 });
let coarse = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
let fine = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let mut half = space(2 * w, 2 * h);
half.to_doc = Affine::scale(0.5);
half.px = 0.5;
half.center = Point::new(w as f64 / 2.0, h as f64 / 2.0);
for (id, p) in [
("artistic.cutout", json!({"edgeSimplicity": 6})),
("artistic.neonGlow", json!({"glowSize": 12, "glowBrightness": 30})),
("artistic.posterEdges", json!({"edgeThickness": 6, "edgeIntensity": 6})),
] {
let (mut a, mut b) = (coarse.clone(), fine.clone());
fx(id, p.clone()).apply(&mut a, w, h, &space(w, h));
fx(id, p).apply(&mut b, 2 * w, 2 * h, &half);
let diff: f64 = (0..h)
.flat_map(|y| (0..w).map(move |x| (x, y)))
.map(|(x, y)| {
let (p, q) = (at(&a, w, x, y), at(&b, 2 * w, 2 * x, 2 * y));
(0..3).map(|k| f64::from(p[k].abs_diff(q[k]))).sum::<f64>() / 3.0
})
.sum::<f64>()
/ (w * h) as f64;
assert!(diff < 12.0, "{id}: mean difference {diff}");
}
}
+282 -3
View File
@@ -1,15 +1,19 @@
//! Raster (painted) effects: descriptions for the renderer.
use serde_json::Value;
use vectorcraft_doc::Effect;
use vectorcraft_doc::color::{BlendMode, Color};
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings};
use vectorcraft_geom::Rect;
use vectorcraft_geom::{Point, Rect};
use crate::merged_params;
use crate::pixel::{self, PixelFx};
use crate::pixel::{self, PixelFx, PixelSpace};
use crate::util::*;
/// Effect › Rasterize's id.
pub const RASTERIZE: &str = "rasterize";
/// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses
/// a Gaussian with σ = blur / 2 (so the visible spread is about 1.5 × blur).
#[derive(Clone, Debug, PartialEq)]
@@ -48,6 +52,8 @@ pub enum RasterFx {
},
/// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…).
Pixel(PixelFx),
/// Effect › Rasterize: the object drawn as an image of square pixels.
Rasterize(Rasterize),
}
impl RasterFx {
@@ -71,6 +77,278 @@ impl RasterFx {
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0,
RasterFx::GaussianBlur { radius } => 1.5 * radius,
RasterFx::Pixel(p) => p.outset(bounds),
RasterFx::Rasterize(r) => r.outset(),
}
}
/// This effect with a Rasterize that takes the document's resolution at `ppi` ([`Rasterize::at_document_ppi`]).
pub fn at_document_ppi(&self, ppi: f64) -> RasterFx {
match self {
RasterFx::Rasterize(r) => RasterFx::Rasterize(r.at_document_ppi(ppi)),
fx => fx.clone(),
}
}
/// What this effect paints over when what it paints around covers `bounds`: Rasterize's image
/// ([`Rasterize::image`], `line` the document's raster grid), `bounds` grown by
/// [`Self::outset`] for the others.
pub fn spread(&self, bounds: Rect, line: f64) -> Rect {
match self {
RasterFx::Rasterize(r) => r.image(bounds, line),
fx => {
let o = fx.outset(bounds);
bounds.inflate(o, o)
}
}
}
}
/// Effect › Rasterize's options, read from its parameters. The image covers the object's bounds
/// (as far as the effects before it spread the object) and `add_around` points around them, in
/// pixels `72 / ppi` points wide whose first one starts at that area's top-left corner, as Object
/// › Rasterize makes its image.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rasterize {
/// Pixels per inch (1 to 2400); `None` takes Document Raster Effects Settings › Resolution.
pub ppi: Option<f64>,
/// The image's color model.
pub color_model: RasterColorModel,
/// A white background: the image is opaque. Otherwise it keeps its transparency.
pub white: bool,
/// Smooth edges (Art Optimized or Type Optimized). Off, each pixel takes the art at its center.
pub anti_alias: bool,
/// Type Optimized: smooth edges with type fitted to the pixels.
pub hinted: bool,
/// The image is clipped to the art's outline.
pub clipping_mask: bool,
/// Room around the art, in points (0 to [`MAX_ADD_AROUND`]).
pub add_around: f64,
}
impl Rasterize {
/// The options in the effect's parameters `p` (defaults merged in).
fn parse(p: &Value) -> Self {
let ppi = (!flag(p, "useDocumentResolution", true)).then(|| num(p, "resolution", 72.0).clamp(1.0, 2400.0));
let color_model = match text(p, "colorModel", "document").to_ascii_lowercase().as_str() {
"grayscale" | "gray" => RasterColorModel::Grayscale,
"bitmap" => RasterColorModel::Bitmap,
_ => RasterColorModel::Document,
};
Self {
ppi,
color_model,
white: text(p, "background", "transparent").eq_ignore_ascii_case("white"),
anti_alias: !text(p, "antiAlias", "art").eq_ignore_ascii_case("none"),
hinted: text(p, "antiAlias", "art").eq_ignore_ascii_case("type"),
clipping_mask: flag(p, "clippingMask", false),
add_around: num(p, "addAround", 0.0).clamp(0.0, MAX_ADD_AROUND),
}
}
/// The width of a pixel of the image in document units: 72 / `ppi`, or `line` (72 / the
/// document's raster effects resolution) when it takes the document's.
pub fn cell(&self, line: f64) -> f64 {
self.ppi.map_or(line, |ppi| 72.0 / ppi)
}
/// These options at `ppi` when they take the document's resolution.
pub fn at_document_ppi(self, ppi: f64) -> Self {
Self { ppi: self.ppi.or(Some(ppi)), ..self }
}
/// How far beyond the object's bounds the image reaches: the room around it and, at a known
/// resolution, one pixel more for the last column and row, which are whole pixels.
pub fn outset(&self) -> f64 {
self.add_around + self.ppi.map_or(0.0, |ppi| 72.0 / ppi)
}
/// How far (document units) from a pixel the content that decides it lies: across its pixel
/// of the image (72 points when the resolution is the document's, not known here).
pub fn reach(&self) -> f64 {
self.cell(72.0) * std::f64::consts::SQRT_2
}
/// The image of art over `bounds`: `bounds` and the room around them, in whole pixels
/// ([`Self::cell`] of `line`) from that area's top-left corner.
pub fn image(&self, bounds: Rect, line: f64) -> Rect {
let cell = self.cell(line);
let area = bounds.inflate(self.add_around, self.add_around);
let (cols, rows) = ((area.width() / cell).ceil().max(1.0), (area.height() / cell).ceil().max(1.0));
Rect::new(area.x0, area.y0, area.x0 + cols * cell, area.y0 + rows * cell)
}
/// Give `pixels` (premultiplied RGBA8), the art drawn on the image's own grid, the background
/// and then the color model, as Object › Rasterize does.
pub fn finish(&self, pixels: &mut [u8]) {
for p in pixels.as_chunks_mut::<4>().0 {
if self.white {
let under = 255 - p[3];
*p = p.map(|c| c.saturating_add(under));
}
}
RasterEffectsSettings { color_model: self.color_model, ..Default::default() }.finish_pixels(pixels);
}
/// Draw `image` into `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as
/// `space` says: `image` is `cols` × `rows` premultiplied RGBA8 pixels covering `rect` in the
/// document. In a view that isn't turned, each pixel of `data` takes the mean of the image over
/// its area (transparent outside the image); otherwise the mean of the image at up to 8 × 8
/// points across it. Does nothing when the sizes don't match.
#[allow(clippy::too_many_arguments)]
pub fn compose(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, image: &[u8], cols: usize, rows: usize, rect: Rect) {
let (px, rest) = data.as_chunks_mut::<4>();
let (src, src_rest) = image.as_chunks::<4>();
let fits = |n: usize, w: usize, h: usize, rest: usize| w > 0 && h > 0 && rest == 0 && w.checked_mul(h) == Some(n);
if !(fits(px.len(), w, h, rest.len()) && fits(src.len(), cols, rows, src_rest.len()) && rect.width() > 0.0 && rect.height() > 0.0) {
return;
}
let (sx, sy) = (cols as f64 / rect.width(), rows as f64 / rect.height());
// Document → image pixels.
let to_image = |p: Point| ((p.x - rect.x0) * sx, (p.y - rect.y0) * sy);
let at = |i: f64, j: f64| {
(i >= 0.0 && j >= 0.0 && i < cols as f64 && j < rows as f64).then(|| src.get(j as usize * cols + i as usize).copied()).flatten()
};
let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0);
// Points across a raster pixel in a turned view: about one an image pixel.
let n = (space.px * sx.max(sy)).ceil().clamp(1.0, 8.0) as usize;
for (k, p) in px.iter_mut().enumerate() {
let (x, y) = ((k % w) as f64, (k / w) as f64);
let mut acc = [0.0f64; 4];
let weight = if straight {
let (a, b) = (to_image(space.to_doc * Point::new(x, y)), to_image(space.to_doc * Point::new(x + 1.0, y + 1.0)));
let (u0, u1, v0, v1) = (a.0.min(b.0), a.0.max(b.0), a.1.min(b.1), a.1.max(b.1));
let (i0, i1) = (u0.floor().max(0.0), u1.ceil().min(cols as f64));
let (j0, j1) = (v0.floor().max(0.0), v1.ceil().min(rows as f64));
let mut j = j0;
while j < j1 {
let mut i = i0;
while i < i1 {
let share = (u1.min(i + 1.0) - u0.max(i)).max(0.0) * (v1.min(j + 1.0) - v0.max(j)).max(0.0);
if let Some(q) = at(i, j) {
for (s, v) in acc.iter_mut().zip(q) {
*s += share * f64::from(v);
}
}
i += 1.0;
}
j += 1.0;
}
(u1 - u0) * (v1 - v0)
} else {
for t in 0..n * n {
let (fx, fy) = (((t % n) as f64 + 0.5) / n as f64, ((t / n) as f64 + 0.5) / n as f64);
let (i, j) = to_image(space.to_doc * Point::new(x + fx, y + fy));
if let Some(q) = at(i.floor(), j.floor()) {
for (s, v) in acc.iter_mut().zip(q) {
*s += f64::from(v);
}
}
}
(n * n) as f64
};
*p = if weight > 0.0 { acc.map(|s| (s / weight).round().clamp(0.0, 255.0) as u8) } else { [0; 4] };
}
}
/// Turn `data`, `w` × `h` premultiplied RGBA8 pixels of the object lying in the document as
/// `space` says, into its image over `bounds` (what the object covers after the effects before
/// this one), when the image is too large to draw on its own grid. Each pixel of the image more
/// than a raster pixel wide takes the mean of the raster pixels whose centers lie in it
/// (anti-aliased) or the raster pixel at its center (not anti-aliased). Smaller pixels are left
/// as drawn. Then the background and the color model apply as Object › Rasterize applies them.
/// What lies outside the image becomes transparent, and in a view that isn't turned the raster
/// pixels along its edges keep the share of them it covers. Does nothing when the sizes don't
/// match. The renderer draws the content with hard edges when it goes into Rasterize with
/// anti-aliasing off and image pixels at least a raster pixel wide.
pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, bounds: Rect) {
let cell = self.cell(space.line);
let (px, rest) = data.as_chunks_mut::<4>();
if w == 0 || h == 0 || !rest.is_empty() || w.checked_mul(h) != Some(px.len()) || !(cell.is_finite() && cell > 0.0 && space.px > 0.0) {
return;
}
let area = self.image(bounds, space.line);
let (cols, rows) = ((area.width() / cell).round().max(1.0), (area.height() / cell).round().max(1.0));
// The pixel of the image (column, row) that a raster pixel's center lies in.
let pixel_at = |x: usize, y: usize| {
let p = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5);
let (c, r) = (((p.x - area.x0) / cell).floor(), ((p.y - area.y0) / cell).floor());
(c >= 0.0 && r >= 0.0 && c < cols && r < rows).then_some((c, r))
};
let wide = cell / space.px;
// The image's pixels the raster covers: columns c0..c1, rows r0..r1.
let seen = space.to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let (c0, c1) = (((seen.x0 - area.x0) / cell).floor().clamp(0.0, cols), ((seen.x1 - area.x0) / cell).ceil().clamp(0.0, cols));
let (r0, r1) = (((seen.y0 - area.y0) / cell).floor().clamp(0.0, rows), ((seen.y1 - area.y0) / cell).ceil().clamp(0.0, rows));
let span = (c1 - c0) as usize;
let slot = |(c, r): (f64, f64)| ((r - r0) as usize).saturating_mul(span).saturating_add((c - c0) as usize);
// Anti-aliased: the sums of each pixel's raster pixels (premultiplied) and their count,
// which saturate past 16 million raster pixels to an image pixel. They are kept for the
// whole grid when it has no more pixels than the raster, else for the pixels the raster
// reaches (a turned view of a narrow raster).
let grid = span.saturating_mul((r1 - r0) as usize);
let whole = grid <= px.len();
let mut dense: Vec<[u32; 5]> = vec![];
let mut sparse: std::collections::HashMap<usize, [u32; 5]> = std::collections::HashMap::new();
if self.anti_alias && wide > 1.0 {
if whole {
dense = vec![[0; 5]; grid];
}
for (i, p) in px.iter().enumerate() {
let Some(k) = pixel_at(i % w, i / w).map(slot) else { continue };
let s = if whole { dense.get_mut(k) } else { Some(sparse.entry(k).or_default()) };
if let Some(s) = s {
for (acc, v) in s.iter_mut().zip(p) {
*acc = acc.saturating_add(u32::from(*v));
}
s[4] = s[4].saturating_add(1);
}
}
}
let sum = |k: usize| if whole { dense.get(k) } else { sparse.get(&k) };
// Not anti-aliased: the raster as drawn, to read each pixel's center from.
let drawn = (!self.anti_alias && wide > 1.0).then(|| px.to_vec());
let to_raster = space.to_doc.inverse();
let model = RasterEffectsSettings { color_model: self.color_model, ..Default::default() };
// In a view that isn't turned, the raster pixels along the image's edges take the share of
// them it covers; otherwise those whose center lies in it are in it.
let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0);
let image = to_raster.transform_rect_bbox(area);
let cover =
|x: f64, y: f64| (image.x1.min(x + 1.0) - image.x0.max(x)).clamp(0.0, 1.0) * (image.y1.min(y + 1.0) - image.y0.max(y)).clamp(0.0, 1.0);
for (i, p) in px.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
let (at, share) = if straight {
// The image pixel nearest the raster pixel's center.
let q = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5);
let at = (((q.x - area.x0) / cell).floor().clamp(0.0, cols - 1.0), ((q.y - area.y0) / cell).floor().clamp(0.0, rows - 1.0));
(Some(at), cover(x as f64, y as f64))
} else {
(pixel_at(x, y), 1.0)
};
let Some(at) = at.filter(|_| share > 0.0) else {
*p = [0; 4];
continue;
};
// An image pixel's color, over the share of the raster pixel the image covers. (A raster
// pixel left as drawn holds art the image covers whole.)
let made = if let Some(s) = sum(slot(at)).filter(|s| s[4] > 0) {
Some([0, 1, 2, 3].map(|k| ((u64::from(s[k]) + u64::from(s[4] / 2)) / u64::from(s[4])).min(255) as u8))
} else {
drawn.as_ref().map(|drawn| {
let center = to_raster * Point::new(area.x0 + (at.0 + 0.5) * cell, area.y0 + (at.1 + 0.5) * cell);
let (x, y) = (center.x.floor(), center.y.floor());
let inside = x >= 0.0 && y >= 0.0 && x < w as f64 && y < h as f64;
if inside { drawn.get(y as usize * w + x as usize).copied().unwrap_or_default() } else { [0; 4] }
})
};
if let Some(v) = made {
*p = if share < 1.0 { v.map(|c| (f64::from(c) * share).round() as u8) } else { v };
}
// White goes under the art first, over the image's share of the pixel, then the color
// model applies, as in Object › Rasterize.
if self.white {
let under = ((255.0 * share).round() as u8).saturating_sub(p[3]);
*p = p.map(|c| c.saturating_add(under));
}
model.finish_pixels(p);
}
}
}
@@ -133,6 +411,7 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
},
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
RASTERIZE => RasterFx::Rasterize(Rasterize::parse(&p)),
id => RasterFx::Pixel(pixel::parse(id, &p)?),
})
})
+31 -3
View File
@@ -34,6 +34,8 @@ pub fn revolve_options(p: &Value) -> Revolve {
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
light_behind: flag(p, "lightBehind", false),
light_color: crate::lighting::light_color(p),
lights: crate::lighting::lights(p),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
@@ -46,6 +48,7 @@ pub fn revolve_options(p: &Value) -> Revolve {
/// Validate before a command mutates the document. Loading external files is still bounded
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
@@ -87,7 +90,9 @@ fn surface(
let b = path.bounds()?;
// A loaded file or a profile edited past the limits keeps drawing its source path instead
// of crashing the frame. Commands validate the same evaluator and report its errors.
let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
let options = revolve_options(&merged_params(REVOLVE, &fx.params));
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
// Visibility is an expansion option, never a change to live geometry or export baking.
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
// failure also retains the complete surface rather than losing any of the user's art.
@@ -102,19 +107,42 @@ fn surface(
let faces = visible.unwrap_or_else(|| {
faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient })
.map(|f| vectorcraft_three_d::VisibleFace {
path: f.path(),
brightness: f.brightness,
depth: f.depth,
gradient: f.gradient,
illumination: f.illumination,
light_gradient: f.light_gradient,
rgb_gradient: f.rgb_gradient,
})
.collect()
});
let children = faces
.into_iter()
.map(|face| {
if let Some(g) = face.rgb_gradient
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
{
return Arc::new(crate::lighting::rgb_gradient_art(n.id, &face.path, &paint, g));
}
if let (true, Paint::Gradient(_), Some(g)) = (colored, &paint, face.light_gradient) {
return Arc::new(crate::lighting::gradient_art(n.id, &face.path, &paint, g));
}
let mut fill = FillLayer::new(paint.clone());
let mut items = vec![];
if let Some(c) = paint.color() {
let [r, g, b] = c.to_rgb();
let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
fill.paint = face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0));
fill.paint = if colored {
face.light_gradient
.map_or_else(|| Paint::solid(crate::lighting::shade(c, face.illumination)), |g| crate::lighting::smooth_paint(g, c))
} else {
face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0))
};
items.push(AppearanceItem::Fill(fill));
} else if colored && matches!(paint, Paint::Gradient(_)) {
items = crate::lighting::fills(&paint, face.illumination, face.brightness, true);
} else {
items.push(AppearanceItem::Fill(fill));
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
+35 -17
View File
@@ -3,11 +3,14 @@
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
#[cfg(test)]
use vectorcraft_doc::color::Color;
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry};
@@ -65,6 +68,8 @@ pub fn view_options(p: &Value) -> View {
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
light_behind: flag(p, "lightBehind", false),
light_color: crate::lighting::light_color(p),
lights: crate::lighting::lights(p),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
@@ -124,6 +129,7 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) ->
/// Validate before a command changes the document; the evaluator bounds loaded files too.
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
let (path, rule) =
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
@@ -139,32 +145,38 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
/// blend mode (so overlapping facets don't add up their transparency).
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode) -> Node {
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
if colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) {
let mut group = crate::lighting::solid_surface(n.id, &facets, paint);
group.opacity = opacity;
group.blend = blend;
return group;
}
// Preserve existing white-light paints, alpha composition and seam joins exactly.
let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) };
let paint = &opaque;
let children = facets
.into_iter()
.map(|face| {
let mut items = vec![];
if let Some(c) = paint.color() {
let [r, g, b] = c.to_rgb();
let s = face.brightness as f32;
items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(r * s, g * s, b * s)))));
} else {
// Gradients and patterns keep their paint, darkened by a black layer.
items.push(AppearanceItem::Fill(FillLayer::new(paint.clone())));
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
shadow.opacity = (1.0 - face.brightness) as f32;
items.push(AppearanceItem::Fill(shadow));
}
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
// stroke of the same paint closes them. No wider than the facet is thick, though: seen
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
// paint over its neighbours.
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
if let Some(g) = face.rgb_gradient
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
{
return Arc::new(crate::lighting::facet_art(n.id, &face.path(), paint, g, seam));
}
let items = crate::lighting::fills(paint, face.illumination, face.brightness, colored);
let items = items
.into_iter()
.flat_map(|item| {
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
let mut edge = StrokeLayer::new(fill.paint.clone(), seam);
if colored {
edge.join = vectorcraft_doc::LineJoin::Round;
}
edge.opacity = fill.opacity;
vec![item, AppearanceItem::Stroke(edge)]
})
@@ -175,6 +187,11 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
if let Some(mask) = mask {
let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0));
group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true)));
}
group
}
@@ -200,6 +217,7 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
let b = path.bounds()?;
let p = crate::merged_params(&fx.id, &fx.params);
let colored = view_options(&p).has_colored_lights();
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds, so they stay together.
let mut out = vec![];
@@ -207,7 +225,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
let (paint, opacity, blend) = paint_of(item);
let (area, rule) = region(path, rule, item);
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
}
return Some(out);
}
@@ -216,7 +234,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
// failing the frame; commands validate the same evaluator and report its errors.
let f = facets(&fx.id, path, rule, b, &p).ok()?;
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))])
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
+44
View File
@@ -834,6 +834,50 @@ fn drop_shadow_darkness_replaces_its_color_when_chosen() {
assert_eq!(darkness(json!({})), None);
}
#[test]
fn rasterize_compose_leaves_the_raster_alone_when_the_sizes_do_not_fit() {
let r = match raster_effects(&[fx(RASTERIZE, json!({}))]).as_slice() {
[RasterFx::Rasterize(r)] => *r,
other => panic!("{other:?}"),
};
let space = PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(1.0, 1.0),
channels: pixel::Channels::Rgb,
line: 1.0,
extent: std::f64::consts::SQRT_2,
};
let compose = |len: usize, w: usize, h: usize, image: &[u8], cols: usize, rows: usize, rect: Rect| {
let mut data = vec![7; len];
r.compose(&mut data, w, h, &space, image, cols, rows, rect);
data
};
// A 2 × 2 red image over a 2 × 2 raster of one-point pixels: each pixel takes the image's color.
let red = [255, 0, 0, 255].repeat(4);
let mut long = red.clone();
long.extend([255, 0]);
let (img, long, area) = (red.as_slice(), long.as_slice(), Rect::new(0.0, 0.0, 2.0, 2.0));
assert_eq!(compose(16, 2, 2, img, 2, 2, area), red);
let cases = [
("w × h is not the raster's pixel count", 16, 1, 2, img, 2, 2, area),
("the raster's length is not a multiple of 4", 18, 2, 2, img, 2, 2, area),
("cols × rows is not the image's pixel count", 16, 2, 2, img, 1, 2, area),
("the image's length is not a multiple of 4", 16, 2, 2, long, 2, 2, area),
("the image has no width", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 0.0, 2.0)),
("the image has no height", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 2.0, 0.0)),
("the image has a negative width", 16, 2, 2, img, 2, 2, Rect::new(2.0, 0.0, 0.0, 2.0)),
("the image has a NaN width", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, f64::NAN, 2.0)),
("the image has a NaN height", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 2.0, f64::NAN)),
];
let failures: Vec<_> = cases
.iter()
.filter(|&&(_, len, w, h, image, cols, rows, rect)| compose(len, w, h, image, cols, rows, rect) != vec![7; len])
.map(|c| c.0)
.collect();
assert!(failures.is_empty(), "{failures:?}");
}
#[test]
fn blend_mode_names() {
use vectorcraft_doc::color::BlendMode;
+4
View File
@@ -23,6 +23,10 @@ fn scale_effect_scales_distances_only() {
scale_effect(&mut e, 2.0);
assert_eq!((&e.params["x"], &e.params["y"], &e.params["blur"]), (&json!(6.0), &json!(8.0), &json!(10.0)));
assert_eq!(e.params["opacity"], json!(75.0));
// Rasterize's Add Around Object is a distance; its resolution isn't.
let mut r = new_effect(RASTERIZE, &json!({"addAround": 10, "resolution": 150})).unwrap();
scale_effect(&mut r, 2.0);
assert_eq!((&r.params["addAround"], &r.params["resolution"]), (&json!(20.0), &json!(150)));
// Outline Stroke without a weight follows its stroke's: nothing to scale.
let mut o = Effect { id: "path.outlineStroke".into(), params: json!(null), visible: true };
scale_effect(&mut o, 2.0);
+59 -5
View File
@@ -27,7 +27,10 @@ fn faces(art: &Node) -> Vec<&Node> {
fn fill_rgb(n: &Node) -> [f32; 3] {
match &n.appearance.items[0] {
AppearanceItem::Fill(f) => f.paint.color().unwrap().to_rgb(),
AppearanceItem::Fill(f) => match &f.paint {
Paint::Gradient(g) => g.gradient.sample(0.5).0.to_rgb(),
p => p.color().unwrap().to_rgb(),
},
_ => panic!("a fill first"),
}
}
@@ -183,10 +186,12 @@ fn an_earlier_perspective_strength_draws_as_its_angle() {
#[test]
fn an_earlier_light_direction_shades_as_its_rotation_and_height() {
fn colours(n: &Node, out: &mut Vec<[f32; 3]>) {
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first()
&& let Some(c) = f.paint.color()
{
out.push(c.to_rgb());
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() {
if let Paint::Gradient(g) = &f.paint {
out.extend(g.gradient.stops.iter().map(|s| s.color.to_rgb()));
} else if let Some(c) = f.paint.color() {
out.push(c.to_rgb());
}
}
for c in n.children().into_iter().flatten() {
colours(c, out);
@@ -265,3 +270,52 @@ fn inflate_reads_twist_taper_and_cap() {
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
}
#[test]
fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
let mut n = square_node();
n.appearance.effects.push(fx(INFLATE, json!({})));
let art = solid_art(&n).unwrap();
let original = faces(&art);
assert!(original.len() > 1000);
assert!(original.iter().any(|f| f.path_data().unwrap().subpaths.iter().any(|p| p.anchors.len() > 3)), "legacy cells remain polygons");
for face in original {
assert!(matches!(face.kind, NodeKind::Path { .. }));
let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") };
assert!((0.05..=0.75).contains(&stroke.width));
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter);
}
// A tiny shape exercises the lower bound, which otherwise disappears at grazing angles.
n.transform(Affine::scale(0.001), false);
let art = solid_art(&n).unwrap();
assert!(faces(&art).iter().any(|face| matches!(&face.appearance.items[1], AppearanceItem::Stroke(s) if s.width == 0.05)));
// An explicit white light overrides a stale legacy color without switching shading paths.
n.appearance.effects[0].params = json!({"lightColor":"#ff0000","lights":[{}]});
let art = solid_art(&n).unwrap();
assert!(faces(&art).iter().all(|face| matches!(face.kind, NodeKind::Path { .. })));
}
#[test]
fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
let mut n = square_node();
n.appearance.effects.push(fx(INFLATE, json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]})));
let art = solid_art(&n).unwrap();
fn count(n: &Node) -> (usize, usize, usize) {
let (mut groups, mut clips, mut paths) = match &n.kind {
NodeKind::Group { clip, .. } => (1, usize::from(*clip), 0),
NodeKind::Path { .. } => (0, 0, 1),
_ => (0, 0, 0),
};
for child in n.children().into_iter().flatten() {
let (g, c, p) = count(child);
groups += g;
clips += c;
paths += p;
}
(groups, clips, paths)
}
let (groups, clips, paths) = count(&art);
assert!(paths > 1000, "exercise a tessellated surface");
assert_eq!(clips, 1);
assert_eq!(groups, 7, "group count must not grow with triangle count");
}
+4
View File
@@ -35,6 +35,8 @@ vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
# The XML parts of Word documents placed as type (place/docx.rs).
roxmltree = "0.21"
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -46,6 +48,8 @@ vectorcraft-affinity = { workspace = true, features = ["synth"] }
crc32fast = { workspace = true }
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
wat = { workspace = true }
# Zstandard streams for the Illustrator editing data fuzz tests.
ruzstd = { workspace = true }
+67 -15
View File
@@ -11,7 +11,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_brush::{self as brush, Brush};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolRegistration};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::{outermost, selected_roots};
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Brush…",
[],
None,
"{type: calligraphic|scatter|art|pattern|bristle, name?, params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
"{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New <Type> Brush), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
has_doc,
brush_new
),
@@ -75,13 +75,21 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
brush_delete
),
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} | {names: [name…]} → {name} | {names}", has_doc, brush_duplicate),
cmd!(
"brush.duplicate",
"Duplicate Brush",
[],
None,
"{name, newName? (trimmed; blank: <name> copy)} | {names: [name…]} → {name} | {names}",
has_doc,
brush_duplicate
),
cmd!(
"brush.options",
"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?} 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. 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
),
@@ -110,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Symbol…",
[],
None,
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic} make a symbol from the selection and replace it with an instance → {name, id, registration, exportType}",
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}",
has_selection,
symbol_new
),
@@ -119,7 +127,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Symbol Options…",
[],
None,
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic} rename a symbol and/or set Symbol Options › Registration and Export Type (default name: current) → {name, registration, exportType}",
"{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array} rename a symbol and/or set Symbol Options › Registration and Export Type and 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1; default name: current) → {name, registration, exportType, nineSlice}",
has_doc,
symbol_options
),
@@ -393,7 +401,8 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
o => return Err(bad(C, format!("unknown brush type `{o}`"))),
};
let mut lib = brush::library(&s.doc()?.doc);
let base_name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]));
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string);
let name = brush::unique_name(&lib, &base_name);
let mut v = json!({"name": name, "type": ty});
if let Some(params) = p.get("params") {
@@ -445,7 +454,8 @@ fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = vec![];
for name in &names {
let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
let new_name = str_param(p, "newName").filter(|_| p.get("names").is_none()).map_or_else(|| format!("{name} copy"), str::to_string);
let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none());
let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string);
b.name = brush::unique_name(&lib, &new_name);
out.push(b.name.clone());
lib.push(b);
@@ -463,7 +473,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(params) = p.get("params") {
merge(&mut v, params);
}
let new_name = str_param(p, "newName").filter(|n| !n.trim().is_empty() && *n != name).map(str::to_string);
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
if let Some(nn) = &new_name {
if lib.iter().any(|b| &b.name == nn) {
return Err(bad(C, format!("a brush named `{nn}` exists")));
@@ -558,6 +568,33 @@ fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
}
}
/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural
/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it
/// on with guides `g` (the symbol's current ones, else the centre third, when none are given).
fn nine_slice_param(p: &Value, cmd: &str, current: Option<SymbolNineSlice>) -> Result<Option<Option<SymbolNineSlice>>> {
let on = match p.get("nineSlice") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "nineSlice must be true or false"))?),
};
let guides =
match p.get("nineSliceGuides") {
None | Some(Value::Null) => None,
Some(v) => Some(SymbolNineSlice::from_value(v).ok_or_else(|| {
bad(cmd, "nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1")
})?),
};
Ok(match (on, guides) {
(Some(false), _) => Some(None),
(None, None) => None,
(_, Some(g)) => Some(Some(g)),
(Some(true), None) => Some(Some(current.unwrap_or_default())),
})
}
fn nine_slice_json(d: &Document, name: &str) -> Value {
d.symbol_nine_slice(name).map_or(Value::Null, SymbolNineSlice::to_value)
}
fn rename_natural_size(d: &mut Document, from: &str, to: &str) {
if from == to {
return;
@@ -614,6 +651,7 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
"size": [w, h],
"registration": d.symbol_registration(&sym.name).0,
"exportType": d.symbol_export_type(&sym.name).id(),
"nineSlice": nine_slice_json(d, &sym.name),
})
})
.collect();
@@ -641,9 +679,10 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let (art, size, c) = normalize(art)?;
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol"));
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
let export = export_type_param(p, C)?.unwrap_or_default();
let nine = nine_slice_param(p, C, None)?.flatten();
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
let id = s.edit("New Symbol", |d, sel| {
@@ -652,12 +691,15 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
set_natural_size(d, &name, Some(size));
d.set_symbol_registration(&name, reg);
d.set_symbol_export_type(&name, export);
d.set_symbol_nine_slice(&name, nine);
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
let id = replace_with_instance(d, &ids, &name, place)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id() }))
Ok(
json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }),
)
}
fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
@@ -665,9 +707,10 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
let name = resolve_symbol(&s.doc()?.doc, p, C)?;
let export = export_type_param(p, C)?;
let reg = registration_param(p, C)?;
let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?;
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
if new_name.is_none() && export.is_none() && reg.is_none() {
return Err(bad(C, "pass newName, registration and/or exportType"));
if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() {
return Err(bad(C, "pass newName, registration, exportType and/or nineSlice"));
}
if let Some(ref n) = new_name
&& n != &name
@@ -691,6 +734,7 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
rename_natural_size(d, &name, n);
d.rename_symbol_registration(&name, n);
d.rename_symbol_export_type(&name, n);
d.rename_symbol_nine_slice(&name, n);
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
}
@@ -701,6 +745,9 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(kind) = export {
d.set_symbol_export_type(&final_name, kind);
}
if let Some(g) = nine {
d.set_symbol_nine_slice(&final_name, g);
}
Ok(())
})?;
let d = &s.doc()?.doc;
@@ -708,6 +755,7 @@ fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
"name": final_name,
"registration": d.symbol_registration(&final_name).0,
"exportType": d.symbol_export_type(&final_name).id(),
"nineSlice": nine_slice_json(d, &final_name),
}))
}
@@ -866,6 +914,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
set_natural_size(d, &name, None);
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
d.clear_symbol_export_type(&name);
d.set_symbol_nine_slice(&name, None);
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.remove(CURRENT_SYMBOL);
}
@@ -879,19 +928,22 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, C)?.to_string();
let d = &s.doc()?.doc;
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty());
let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string));
let size = natural_size(d, &name);
let reg = d.symbol_registration(&name);
let export = d.symbol_export_type(&name);
let nine = d.symbol_nine_slice(&name);
s.edit("Duplicate Symbol", |d, _| {
let art = d.reid(&sym.art);
d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) });
set_natural_size(d, &new, Some(size));
d.set_symbol_registration(&new, reg);
d.set_symbol_export_type(&new, export);
d.set_symbol_nine_slice(&new, nine);
Ok(())
})?;
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id() }))
Ok(json!({ "name": new, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }))
}
fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
+58 -5
View File
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down)}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -859,9 +859,16 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
Ok(library)
}
/// The most anchor points an Image Trace may make: a photo traced at high fidelity can make
/// millions, more than the canvas draws smoothly or a laptop has memory for, so the trace is
/// refused with advice instead (#525).
/// The most anchor points an Image Trace may make, a guard for memory: a trace past it is refused
/// with advice instead (#525). A trace keeps about 90 bytes an anchor in the document (measured on
/// code-drawn photos of 2000 × 2000 to 6000 × 4000 pixels), so 8,000,000 anchors are about
/// 0.7 GB, with a peak of about 1 GB while tracing: a 4000 × 3000 photo at High Fidelity Photo
/// makes about 1.9 million, and even at Paths 100, Noise 1 and every tone about 7.9 million. On the
/// web, where a page has at most 4 GB of memory, the limit stays at 2,000,000.
#[cfg(not(target_arch = "wasm32"))]
pub const MAX_TRACE_ANCHORS: usize = 8_000_000;
/// The most anchor points an Image Trace may make on the web (see the native limit).
#[cfg(target_arch = "wasm32")]
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`.
@@ -975,6 +982,7 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
let raster = tr::Raster::decode_to_fit(&blob.bytes).map_err(|e| EngineError::Other(e.to_string()))?;
let library = trace_library(s, &mut params)?;
let res = tr::trace_within(&raster, &params, MAX_TRACE_ANCHORS).map_err(|e| EngineError::Other(e.to_string()))?;
let warnings = trace_warnings((img.width, img.height), (raster.width, raster.height));
// Pixel space of the decoded raster → the image object's pixel space → document.
let sx = img.width.max(1) as f64 / raster.width.max(1) as f64;
let sy = img.height.max(1) as f64 / raster.height.max(1) as f64;
@@ -1044,7 +1052,34 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors }))
Ok(json!({ "id": id.0, "paths": npaths, "anchors": anchors, "colors": colors, "warnings": warnings }))
}
/// Images larger than this on a side are traced, but more slowly: the trace says so.
pub const LARGE_TRACE_SIDE: u32 = 1500;
/// What a trace of an image of `size` pixels, traced at `traced` pixels, tells the user: that a
/// large image takes longer (the public documentation's warning for images over 1500 px), and that
/// one over the tracer's [`tr::MAX_PIXELS`] was traced scaled down.
pub(crate) fn trace_warnings(size: (u32, u32), traced: (u32, u32)) -> Vec<String> {
let mut out = vec![];
if size.0.max(size.1) > LARGE_TRACE_SIDE {
out.push(format!(
"The image is {} × {} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster.",
size.0, size.1
));
}
if u64::from(traced.0) * u64::from(traced.1) < u64::from(size.0) * u64::from(size.1) {
out.push(format!(
"The image was traced at {} × {} pixels, scaled down from {} × {} ({} megapixels at most).",
traced.0,
traced.1,
size.0,
size.1,
tr::MAX_PIXELS >> 20
));
}
out
}
fn trace_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
@@ -1113,3 +1148,21 @@ fn trace_expand(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({ "ids": ids_json(&targets) }))
}
#[cfg(test)]
mod trace_limit_tests {
use super::{MAX_TRACE_ANCHORS, trace_warnings};
#[test]
fn trace_warnings_name_a_large_or_scaled_down_image() {
assert!(trace_warnings((1500, 1500), (1500, 1500)).is_empty());
assert_eq!(trace_warnings((1501, 10), (1501, 10)).len(), 1);
let w = trace_warnings((16000, 12000), (9459, 7094));
assert_eq!(w.len(), 2, "{w:?}");
assert!(w[0].contains("16000 × 12000") && w[1].contains("9459 × 7094") && w[1].contains("64 megapixels"), "{w:?}");
}
// A 4000 × 3000 photo at High Fidelity Photo makes about 1.9 million anchors, which must fit;
// the limit still guards memory (about 90 bytes an anchor). Checked when the tests build.
const _: () = assert!(MAX_TRACE_ANCHORS >= 2_000_000 && MAX_TRACE_ANCHORS <= 16_000_000);
}
+9 -3
View File
@@ -109,7 +109,9 @@ impl Clipboard {
// The nearest enclosing layer (or sublayer).
source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone()));
}
let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b));
// Symbol instances are measured by their art.
let symbols = d.symbol_boxes();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b));
// Art on no artboard has no source artboard: in-place pastes put it back where it was (#1924).
let board = bounds.and_then(|b| d.artboard_at(b.center()));
Self {
@@ -127,9 +129,13 @@ impl Clipboard {
Self::gather(src, nodes)
}
/// Union of the objects' visual bounds.
/// Union of the objects' visual bounds, with symbol instances measured by the art of the
/// symbols the clipboard holds.
pub fn bounds(&self) -> Option<Rect> {
self.nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b))
let mut d = Document::new(100.0, 100.0);
d.symbols = self.symbols.clone();
let symbols = d.symbol_boxes();
self.nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))
}
/// The swatch name conflicts pasting into `st` raises, as (pasted, document) swatches: none
+45 -22
View File
@@ -130,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clipboard as PNG",
[],
None,
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px)} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty)",
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px), warnings} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty); warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)",
always,
export_png
),
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clipboard as PDF",
[],
None,
"{} → {dataBase64} a one-page PDF of the copied objects, the page their visual bounds (null when the clipboard is empty)",
"{} → {dataBase64, warnings} a one-page PDF of the copied objects, the page their visual bounds (dataBase64 null when the clipboard is empty); warnings as for document.export",
always,
export_pdf
),
@@ -218,6 +218,9 @@ fn type_only(n: &Node) -> bool {
}
}
/// A PNG of the copied objects: (bytes, width, height in pixels, the encoder's warnings).
pub type Png = (Vec<u8>, u32, u32, Vec<String>);
impl Clipboard {
/// The copied type objects' text, one object per line (`None` unless it holds type only).
pub fn text(&self) -> Option<String> {
@@ -243,21 +246,21 @@ impl Clipboard {
Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b))
}
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height); `None` when
/// there are none.
pub fn png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height, the warnings
/// [`fileio::encode_with_warnings`] gives); `None` when there are none.
pub fn png(&self, scale: f64) -> Result<Option<Png>> {
const C: &str = "clipboard.exportPng";
let Some((doc, b)) = self.cropped() else { return Ok(None) };
let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?;
let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
Ok(Some((png, w, h)))
let (png, warnings) = fileio::encode_with_warnings(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
Ok(Some((png, w, h, warnings)))
}
/// A one-page PDF of the objects (without the native document inside); `None` when there are
/// none.
pub fn pdf(&self) -> Result<Option<Vec<u8>>> {
/// A one-page PDF of the objects (without the native document inside) → (bytes, the encoder's
/// warnings); `None` when there are none.
pub fn pdf(&self) -> Result<Option<(Vec<u8>, Vec<String>)>> {
let Some((doc, _)) = self.cropped() else { return Ok(None) };
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
}
/// The MIME types of [`Self::flavours`], without making them.
@@ -275,23 +278,38 @@ impl Clipboard {
/// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large
/// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side.
pub fn flavours(&self, svg: bool, pdf: bool) -> Vec<Flavour> {
self.flavours_with_warnings(svg, pdf).0
}
/// [`Self::flavours`], with the warnings of the PDF and PNG encoders, each once (an image that
/// couldn't be decoded, type in the fallback font).
pub fn flavours_with_warnings(&self, svg: bool, pdf: bool) -> (Vec<Flavour>, Vec<String>) {
let types = self.flavour_types(svg, pdf);
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
let mut out = vec![];
let mut warnings: Vec<String> = vec![];
let mut keep = |(bytes, notes): (Vec<u8>, Vec<String>)| {
for w in notes {
if !warnings.contains(&w) {
warnings.push(w);
}
}
bytes
};
for mime in types {
let data = match mime {
TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes),
SVG => svg.clone().map(String::into_bytes),
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten().map(&mut keep),
_ => {
let side = self.bounds().map_or(1.0, |b| b.width().max(b.height()));
let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0);
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, _, _, notes)| keep((png, notes)))
}
};
out.extend(data.map(|data| Flavour { mime, data }));
}
out
(out, warnings)
}
}
@@ -302,15 +320,15 @@ impl Session {
self.clipboard.text()
}
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
/// when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height, the
/// encoder's warnings); `None` when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<Png>> {
self.clipboard.png(scale)
}
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
/// clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
/// A one-page PDF of the copied objects (without the native document inside) → (bytes, the
/// encoder's warnings); `None` when the clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<(Vec<u8>, Vec<String>)>> {
self.clipboard.pdf()
}
@@ -356,13 +374,18 @@ fn export_png(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("clipboard.exportPng", "scale must be a number above 0, at most 64"));
}
Ok(match s.clipboard_png(scale)? {
Some((png, w, h)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h }),
Some((png, w, h, warnings)) => {
json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h, "warnings": warnings })
}
None => json!({ "dataBase64": null }),
})
}
fn export_pdf(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "dataBase64": s.clipboard_pdf()?.map(|b| vectorcraft_format::base64_encode(&b)) }))
Ok(match s.clipboard_pdf()? {
Some((pdf, warnings)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&pdf), "warnings": warnings }),
None => json!({ "dataBase64": null }),
})
}
fn export_text(s: &mut Session, _: &Value) -> Result<Value> {
+13 -6
View File
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
use super::fileio::{default_name, encode, isolated, write_file};
use super::fileio::{default_name, encode_with_warnings, isolated, write_file};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export CSS",
[],
None,
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
has_doc,
export
),
@@ -100,12 +100,19 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let css = format!("{}\n", sheet.text());
let doc = &s.doc()?.doc;
// The pictures of rasterized art.
// Encode the pictures of rasterized art, and add each of their warnings once.
let mut images = Vec::new();
let mut warnings = sheet.warnings.clone();
for r in &sheet.rules {
let Some(name) = &r.image else { continue };
let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?;
images.push((name, encode(&d, "png", &json!({}))?));
let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?;
images.push((name, png));
for w in notes {
if !warnings.contains(&w) {
warnings.push(w);
}
}
}
let n = sheet.rules.len();
match str_param(p, "path") {
@@ -118,12 +125,12 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
write_file(&at, bytes)?;
written.push(at);
}
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": sheet.warnings }))
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": warnings }))
}
None => {
let images: Vec<Value> =
images.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": sheet.warnings }))
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": warnings }))
}
}
}
+9 -12
View File
@@ -746,13 +746,9 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Forget the Puppet Warp pins: not an undo step, and a saved document stays saved (pins are
/// never saved).
/// never saved, [`crate::DocState::change_unsaved`]).
pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) {
let clean = !st.is_dirty();
Arc::make_mut(&mut st.doc).puppet = None;
if clean {
st.mark_saved();
}
st.change_unsaved(|d| Arc::make_mut(d).puppet = None);
}
fn puppet_pins(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1335,7 +1331,8 @@ fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and
/// in every undo state (the flat view is never an undo step of its own).
/// in every undo state (the flat view is never an undo step of its own, and never saved: a saved
/// state stays saved, [`crate::DocState::change_unsaved`]).
pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
let clear = |doc: &mut Arc<Document>| {
// Undo steps keep no lookup cache: look without building one, and drop the one an edit
@@ -1346,14 +1343,14 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
p.editing = false;
}
};
let saved = Arc::ptr_eq(&st.doc, &st.saved_doc);
clear(&mut st.doc);
if saved {
st.saved_doc = st.doc.clone();
}
st.change_unsaved(clear);
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
let saved = Arc::ptr_eq(&e.doc, &st.saved_doc);
clear(&mut e.doc);
e.doc.drop_lookup_cache();
if saved {
st.saved_doc = e.doc.clone();
}
}
if let Some(it) = &mut st.interaction {
clear(&mut it.doc);
+11 -3
View File
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Lock Guides",
["View", "Guides"],
Some("Cmd+Alt+;"),
"{locked?: bool} toggle (or set) the session's guide lock → {locked}",
"{locked?: bool} toggle (or set) the session's guide lock; locking deselects the ruler guides and guide paths in every open document → {locked}",
has_doc,
lock_guides
),
@@ -601,12 +601,20 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
s.menu.guides_locked = v;
// Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets
// those go).
// Locked guides can't stay selected, ruler guides or guide paths, in any open document
// ([`Session::select`] lets those go in the active one).
let st = s.doc()?;
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
s.select(|_, sel| sel.guides.clear())?;
}
if v {
let active = s.active;
for (i, st) in s.docs.iter_mut().enumerate() {
if Some(i) != active && crate::deselect_guides(&st.doc, &mut st.selection) {
st.revision += 1;
}
}
}
Ok(json!({ "locked": v }))
}
+3 -3
View File
@@ -121,7 +121,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value
"typographersQuotes" => s.typographers_quotes = flag(k, v)?,
"superscript" => s.superscript = script_param(k, v, s.superscript)?,
"subscript" => s.subscript = script_param(k, v, s.subscript)?,
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, 200.0)?,
"smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, vectorcraft_doc::setup::MAX_SMALL_CAPS)?,
"exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?,
"backgroundContents" => s.background = background_param(v, C)?,
// Read-only values `document.setup` reports.
@@ -223,8 +223,8 @@ fn script_param(k: &str, v: &Value, mut m: ScriptMetrics) -> Result<ScriptMetric
let o = v.as_object().ok_or_else(|| bad(C, format!("{k} must be {{size?, position?}}")))?;
for (key, x) in o {
match key.as_str() {
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, 500.0)?,
"position" => m.position = percent(&format!("{k}.position"), x, -500.0, 500.0)?,
"size" => m.size = percent(&format!("{k}.size"), x, 1.0, vectorcraft_doc::setup::MAX_SCRIPT)?,
"position" => m.position = percent(&format!("{k}.position"), x, -vectorcraft_doc::setup::MAX_SCRIPT, vectorcraft_doc::setup::MAX_SCRIPT)?,
_ => return Err(bad(C, format!("unknown {k} key `{key}` (size, position)"))),
}
}
+17 -1
View File
@@ -201,6 +201,16 @@ fn subpath_len(sp: &SubPath) -> f64 {
PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum()
}
/// The bound, in points, on the length of a `path.reshapeSegment` delta and on the x and y of a
/// point given to `path.convertAnchor` or `path.freehand`. The blend spine commands set the same
/// bound (`object.blend.spine.reshapeSegment`, `object.blend.spine.moveAnchor`).
const MAX_INPUT: f64 = 1e9;
/// Whether `q` is finite and its x and y are each smaller than [`MAX_INPUT`] in size.
fn within_input_bound(q: Point) -> bool {
q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT
}
fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
@@ -541,6 +551,9 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.freehand";
let samples = samples_param(p, "points", C)?;
if samples.iter().any(|(q, _)| !within_input_bound(*q)) {
return Err(bad(C, "x and y must be finite"));
}
let pressure = stroke_pressure(&samples);
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
@@ -857,6 +870,9 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
(Some(x), Some(y)) => Some(Point::new(x, y)),
_ => None,
};
if target.is_some_and(|q| !within_input_bound(q)) {
return Err(bad(C, "x and y must be finite"));
}
s.edit("Convert Anchor Point", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
@@ -886,7 +902,7 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
let seg = usize_req(p, "segment", C)?;
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
if !(dv.x.is_finite() && dv.y.is_finite()) {
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
return Err(bad(C, "invalid delta"));
}
let perpendicular = bool_or(p, "perpendicular", false);
+110 -25
View File
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, Unit};
use vectorcraft_doc::{Document, Node, NodeId, RulerMode, Unit};
use vectorcraft_geom::{Affine, Point, Vec2};
use super::clipboard::SwatchChoices;
@@ -37,13 +37,21 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_units
),
cmd!(query "document.rulerOrigin", "Ruler Origin", [], None, "{} → {x, y} the rulers' zero point, in absolute document points", has_doc, |s, _| Ok(json!(s.doc()?.doc.ruler_origin))),
cmd!(
query "document.rulerOrigin",
"Ruler Origin",
[],
None,
"{artboard?: index=0 (the active artboard, which artboard rulers measure from)} → {x, y} the rulers' zero point, in absolute document points",
has_doc,
ruler_origin
),
cmd!(
"document.setRulerOrigin",
"Ruler Origin",
[],
None,
"{x, y} set the rulers' zero point in absolute document points; art, guides and command coordinates stay in place",
"{x, y, artboard?: index=0} set the rulers' zero point in absolute document points: the document's with global rulers, that artboard's with artboard rulers (it moves with the artboard); art, guides and command coordinates stay in place",
has_doc,
set_ruler_origin
),
@@ -52,10 +60,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Reset Ruler Origin",
[],
None,
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner",
"{artboard?: index=0} reset the rulers' zero point to that artboard's top-left corner (with artboard rulers, that artboard's own zero point)",
has_doc,
reset_ruler_origin
),
cmd!(
"view.globalRulers",
"Change to Global Rulers",
["View", "Rulers"],
Some("Cmd+Alt+R"),
"{global?: bool (default: toggle)} global rulers measure the whole document from one zero point; artboard rulers (a new document's) from the active artboard's top-left corner, or the zero point set for it. Saved with the document → {global}",
has_doc,
global_rulers
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
@@ -229,33 +246,91 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given.
fn artboard_param(s: &Session, p: &Value, c: &str) -> Result<usize> {
let index = match p.get("artboard") {
None => 0,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(c, "index out of range"))?,
};
if index >= s.doc()?.doc.artboards.len() {
return Err(bad(c, "index out of range"));
}
Ok(index)
}
fn ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
// A document without artboards measures from its global zero point.
let index = if d.artboards.is_empty() && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, "document.rulerOrigin")? };
Ok(json!(s.doc()?.doc.ruler_origin_for(index)))
}
fn set_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.setRulerOrigin";
let origin = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
change_ruler_origin(s, origin, "Ruler Origin")
let index = if s.doc()?.doc.rulers == RulerMode::Global && p.get("artboard").is_none() { 0 } else { artboard_param(s, p, C)? };
change_ruler_origin(s, index, Some(origin), "Ruler Origin")
}
fn reset_ruler_origin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.resetRulerOrigin";
let index = match p.get("artboard") {
None => 0,
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(C, "index out of range"))?,
};
let ab = s.doc()?.doc.artboards.get(index).ok_or_else(|| bad(C, "index out of range"))?;
change_ruler_origin(s, Point::new(ab.rect.x0, ab.rect.y0), "Reset Ruler Origin")
let index = artboard_param(s, p, "document.resetRulerOrigin")?;
change_ruler_origin(s, index, None, "Reset Ruler Origin")
}
fn change_ruler_origin(s: &mut Session, origin: Point, label: &str) -> Result<Value> {
if !origin.is_finite() || origin.x.abs() > crate::MAX_COORD || origin.y.abs() > crate::MAX_COORD {
/// Move the rulers' zero point to `origin`, or back to artboard `index`'s top-left corner: the
/// document's (global rulers) or that artboard's (artboard rulers, kept as an offset from its
/// corner so it follows the artboard).
fn change_ruler_origin(s: &mut Session, index: usize, origin: Option<Point>, label: &str) -> Result<Value> {
let d = &s.doc()?.doc;
let global = d.rulers == RulerMode::Global;
let corner = d.artboards.get(index).map(|ab| Point::new(ab.rect.x0, ab.rect.y0));
let target = origin.or(corner).unwrap_or(Point::ZERO);
if !target.is_finite() || target.x.abs() > crate::MAX_COORD || target.y.abs() > crate::MAX_COORD {
return Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()));
}
if s.doc()?.doc.ruler_origin != origin {
if global {
if d.ruler_origin != target {
s.edit(label, |d, _| {
d.ruler_origin = target;
Ok(())
})?;
}
return Ok(json!(target));
}
let Some((id, corner)) = d.artboards.get(index).map(|ab| ab.id).zip(corner) else {
return Err(bad("document.setRulerOrigin", "index out of range"));
};
let offset = Some(target - corner).filter(|o| *o != Vec2::ZERO);
if d.artboard_ruler_origins.get(&id).copied() != offset {
s.edit(label, |d, _| {
d.ruler_origin = origin;
// Origins of artboards that no longer exist go too.
let ids: BTreeSet<u32> = d.artboards.iter().map(|ab| ab.id).collect();
d.artboard_ruler_origins.retain(|id, _| ids.contains(id));
match offset {
Some(o) => d.artboard_ruler_origins.insert(id, o),
None => d.artboard_ruler_origins.remove(&id),
};
Ok(())
})?;
}
Ok(json!(origin))
Ok(json!(target))
}
/// View › Rulers › Change to Global / Artboard Rulers.
fn global_rulers(s: &mut Session, p: &Value) -> Result<Value> {
let current = s.doc()?.doc.rulers == RulerMode::Global;
let global = match p.get("global") {
None => !current,
Some(v) => v.as_bool().ok_or_else(|| bad("view.globalRulers", "global must be true or false"))?,
};
if global != current {
let label = if global { "Change to Global Rulers" } else { "Change to Artboard Rulers" };
s.edit(label, |d, _| {
d.rulers = if global { RulerMode::Global } else { RulerMode::Artboard };
Ok(())
})?;
}
Ok(json!({ "global": global }))
}
/// A typing session in progress (the Type tool previews the whole session as one interaction).
@@ -296,6 +371,7 @@ pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let guides_locked = s.menu.guides_locked;
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
@@ -308,12 +384,17 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
// Guides locked since the step was made stay deselected.
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
super::maskedit::follow_history(st, editing);
Ok(json!({ "undone": label }))
}
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let guides_locked = s.menu.guides_locked;
let st = s.doc_mut()?;
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let label = e.label.clone();
@@ -326,6 +407,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
super::maskedit::follow_history(st, editing);
Ok(json!({ "redone": label }))
}
@@ -344,22 +428,23 @@ pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
}
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): in
/// an isolated compound path its paths are objects of their own, so the hole of a ring moves
/// without the ring. Paint and the rest still go to the compound path, which holds the paint.
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a
/// path of a compound path selected on its own (Group Selection, or in the isolated compound path)
/// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the
/// rest still go to the compound path, which holds the paint.
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), st.isolation))
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), true))
}
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
roots_within(doc, ids, None)
roots_within(doc, ids, false)
}
/// [`roots_of`], less the swap for the members of `isolation` when it is a compound path.
fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) -> Vec<NodeId> {
/// [`roots_of`]; with `members`, a compound path's member listed stays itself.
fn roots_within(doc: &Document, ids: Vec<NodeId>, members: bool) -> Vec<NodeId> {
// Every id's place in the tree, found in one walk (a lookup per id is a walk per id).
let paths = doc.index_paths(ids.iter().copied());
// A compound-path member acts as its compound (compounds are one object).
@@ -369,7 +454,7 @@ fn roots_within(doc: &Document, ids: Vec<NodeId>, isolation: Option<NodeId>) ->
let path = paths.get(&id);
let parent = path.and_then(|p| p.split_last()).map(|(_, up)| up).filter(|up| !up.is_empty());
match parent.and_then(|up| doc.node_at(up).map(|n| (up, n))) {
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && Some(n.id) != isolation => (n.id, Some(up.to_vec())),
Some((up, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) && !members => (n.id, Some(up.to_vec())),
_ => (id, path.cloned()),
}
})
+12 -5
View File
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Appearance",
["Object"],
None,
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}",
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
can_expand,
expand_appearance
),
@@ -426,9 +426,10 @@ fn is_container(n: &Node) -> bool {
}
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image rendered at
/// the document's raster effects resolution, with whether they all paint below it (shadows and
/// outer glows: the image then holds just them). A group's or layer's image leaves out its
/// members' raster effects, which are expanded with them. `None` when it has none.
/// the document's raster effects resolution or a Rasterize effect's finer one, with whether they
/// all paint below it (shadows and outer glows: the image then holds just them). A group's or
/// layer's image leaves out its members' raster effects, which are expanded with them. `None`
/// when it has none.
fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> {
let fx = rasterfx::raster_fx(n);
if fx.is_empty() {
@@ -551,6 +552,7 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let roots = appearance_targets(s, p)?;
let failed = vectorcraft_render::decode_failures();
let ids = s.edit("Expand Appearance", |d, _| {
let brushes = vectorcraft_brush::library(d);
let mut out = vec![];
@@ -562,7 +564,12 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(out)
})?;
Ok(json!({ "ids": ids_json(&ids) }))
let mut out = json!({ "ids": ids_json(&ids) });
// Raster effects become images rendered from the art.
if let Some(w) = rasterfx::undecoded_warning(failed) {
out["warning"] = json!(w);
}
Ok(out)
}
#[cfg(test)]
+114 -5
View File
@@ -140,8 +140,9 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
}
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
// Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder
// destination uses the same name). `file_part` writes `/`, `\` and `:` in the data set's name as
// `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
// destination uses the same name). `file_part` writes the characters Windows does not allow in a
// file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) in the data set's
// name as `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name))));
let steps = filter_steps(run.steps, true, false);
let run = Run { steps: &steps, ..*run };
@@ -246,7 +247,8 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result
Ok(())
}
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on.
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the
/// extension of the step's `format` ([`format_extension`]), else `default_ext`.
#[cfg(not(target_arch = "wasm32"))]
fn rewrite_paths(
steps: &[Value],
@@ -271,8 +273,10 @@ fn rewrite_paths(
}
let params = c.get("params").cloned().unwrap_or(json!({}));
let mut params = params.as_object().cloned().unwrap_or_default();
let ext = params.get("format").and_then(Value::as_str).unwrap_or(default_ext);
let ext = if ext.is_empty() { default_ext } else { ext };
let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
Some(f) => format_extension(f),
None => default_ext.to_string(),
};
let file = std::path::Path::new(folder).join(format!("{stem}.{ext}"));
params.insert("path".into(), json!(file.to_string_lossy()));
if let Some(o) = c.as_object_mut() {
@@ -283,6 +287,21 @@ fn rewrite_paths(
.collect()
}
/// The extension of the file a step writes in `format` (an id or extension, as [`super::format`]
/// reads it: any case, a leading dot allowed): the extension as written, in lower case and without
/// the dot, when it is one of the format's (`SVG` → `svg`, `.JPEG` → `jpeg`), else the format's
/// first (`png8` → `png`, `template` → `vctemplate`). An unknown format (`xyz`) keeps its spelling,
/// and the step fails with its command's error.
#[cfg(not(target_arch = "wasm32"))]
fn format_extension(format: &str) -> String {
let written = format.trim_start_matches('.').to_ascii_lowercase();
match super::format(format) {
Some(f) if f.extensions.contains(&written.as_str()) => written,
Some(f) => f.extensions.first().map_or(written, |e| (*e).to_string()),
None => format.to_string(),
}
}
/// Readable files under `folder` (and optionally its subfolders), capped.
#[cfg(not(target_arch = "wasm32"))]
fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Vec<String>> {
@@ -410,6 +429,70 @@ mod tests {
assert!(dest.join("only.svg").is_file());
}
/// Override Export names each file with the extension of the format the step names, however
/// the step spells it; a format given by one of its extensions keeps that extension.
#[test]
fn batch_override_export_names_files_with_the_format_s_extension() {
let src = tmp("ext-src");
write_svg(&src, "a.svg", "#f00");
let cases = [(".svg", "a.svg"), ("SVG", "a.svg"), ("svg", "a.svg"), ("png8", "a.png"), ("template", "a.vctemplate"), (".JPEG", "a.jpeg")];
for (i, (format, want)) in cases.into_iter().enumerate() {
let dest = tmp(&format!("ext-out-{i}"));
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.export", "params": {"format": format}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{format}: {r}");
assert_eq!(file_names(&dest), [want], "{format}: {r}");
}
// An unknown format keeps its spelling, and the step fails with the export's error.
let dest = tmp("ext-out-unknown");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.export", "params": {"format": "xyz"}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 1, "{r}");
assert!(r["processed"][0]["error"].as_str().is_some_and(|e| e.contains("unknown format `xyz`")), "{r}");
assert!(file_names(&dest).is_empty(), "{r}");
}
/// Override Save names each file with the extension of the format the step names, also over
/// data sets.
#[test]
fn batch_override_save_names_files_with_the_format_s_extension() {
for (i, format) in [".svg", "SVG", ".PDF"].into_iter().enumerate() {
let dest = tmp(&format!("ext-save-{i}"));
let mut s = with_data_sets(&["a"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "file.saveAs", "params": {"format": format}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{format}: {r}");
let want = format!("Untitled_a.{}", format.trim_start_matches('.').to_ascii_lowercase());
assert_eq!(file_names(&dest), [want], "{format}: {r}");
}
}
#[test]
fn batch_datasets_applies_each_row() {
let dest = tmp("sets");
@@ -516,6 +599,32 @@ mod tests {
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
}
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name:
/// Batch writes them as `-` in a data set's name, on every platform.
#[test]
fn batch_saves_data_sets_named_with_characters_windows_does_not_allow() {
let dest = tmp("set-names-win");
let mut s = with_data_sets(&["a?b", "c*d<e>", "f\"g|h", "i\u{7}j"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"stopOnError": false,
"commands": [{"command": "object.unlockAll", "params": {}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(
file_names(&dest),
["Untitled_a-b.vectorcraft", "Untitled_c-d-e-.vectorcraft", "Untitled_f-g-h.vectorcraft", "Untitled_i-j.vectorcraft"],
"{r}"
);
}
#[test]
fn batch_gives_each_data_set_a_file_of_its_own() {
let dest = tmp("set-names-same");
+4 -1
View File
@@ -99,7 +99,7 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
/// written.
/// written. Lock Guides stays as the steps left it, and while it is on the guides are deselected.
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
let open = std::mem::take(&mut s.docs);
s.docs = start;
@@ -107,5 +107,8 @@ fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
// Past the newest revision the document reached, so the restored one redraws.
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
st.revision = newest.saturating_add(1);
if s.menu.guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
}
}
+7 -3
View File
@@ -385,7 +385,7 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
/// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet,
/// features approximated or left out; every format that draws type: fonts written in the fallback
/// font).
/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame).
pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
let f = super::writable(C, Some(format), None)?;
encode_all(doc, f.id, p)?.single(f)
@@ -417,8 +417,10 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
vectorcraft_doc::cancel::check()?;
// An image the renderer couldn't decode draws nothing: say so rather than write a picture
// that silently lacks it.
if f.raster && vectorcraft_render::decode_failures() > failed {
enc.warnings.push("an image couldn't be decoded and is missing from the picture".into());
if f.raster
&& let Some(w) = crate::cmd::rasterfx::undecoded_warning(failed)
{
enc.warnings.push(w.into());
}
// Formats that draw type: missing fonts come out in the fallback font (PDF and SVG say so
// themselves: SVG only when it outlines or embeds fonts).
@@ -527,6 +529,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let regions: Vec<Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(&regions)));
if format == RasterFormat::WebP && o.lossless == Some(false) {
enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
}
+2
View File
@@ -270,6 +270,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
if pages.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let regions: Vec<Rect> = pages.iter().map(|(_, region, _)| *region).collect();
let transparent = doc.layers.iter().any(|l| l.shows_transparency());
let flat = flatten_document(doc, &set.flatten)?;
let drawn = flat.as_ref().unwrap_or(doc);
@@ -302,6 +303,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
}
}
}
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(&regions)));
Ok(enc)
}
+4 -5
View File
@@ -12,8 +12,7 @@ use crate::DocState;
use super::super::*;
use super::encode::single_artboard;
use super::{
ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded,
write_or_return,
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
};
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
@@ -224,7 +223,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
}
/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless
/// `transparent`.
/// `transparent`, with the encoder's warnings.
pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportForOffice";
if let Some(path) = str_param(p, "path") {
@@ -242,8 +241,8 @@ pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
}
let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" };
let opts = with_black_output(s, &json!({ "ppi": ppi, "background": background, "artboard": artboard }));
let bytes = encode(doc, "png", &opts)?;
let (bytes, warnings) = encode_with_warnings(doc, "png", &opts)?;
// The pixel size, from the PNG header.
let side = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map_or(0, u32::from_be_bytes);
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20) }))
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20), "warnings": warnings }))
}
+25 -7
View File
@@ -168,6 +168,12 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
if !read {
return (doc, warnings);
}
// 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
// outlines are dropped.
if !vectorcraft_pdf::holds_outlines(&doc) {
warnings.retain(|w| !vectorcraft_pdf::is_outlines_note(w));
}
// 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);
@@ -264,9 +270,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
{
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
}
// Threaded type read from a file flows through its frames.
// Threaded type read from a file flows through its frames (the threaded type of a restored
// document keeps its text as saved), and every frame of a thread takes its first frame's
// Optical Margin Alignment setting.
if !doc.text_threads.is_empty() && !restored {
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
} else if !doc.text_threads.is_empty() {
crate::cmd::threads::share_optical_margin(&mut doc);
}
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
@@ -442,10 +452,10 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its
/// header says so; the pixels aren't read): the renderer, which has the same allowance, draws
/// nothing for them, so Open, Place and the Links panel say it (#1479). `None` for images that
/// decode, and for bytes that aren't an image.
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode,
/// counted as RGBA (4 bytes a pixel) or as the decoder gives them when that is more (its header
/// says so; the pixels aren't read): Open, Place and the Links panel say it (#1479). `None` for
/// images within the allowance, and for bytes that aren't an image.
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
use image::ImageDecoder as _;
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
@@ -453,7 +463,10 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
}
let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
let (w, h) = d.dimensions();
(d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
// The allowance also counts the pixels as RGBA: an image of fewer than 4 bytes a pixel (gray,
// gray with alpha, RGB) takes more than the decoder counts (#1717).
let rgba_over = u64::from(w) * u64::from(h) > MAX_RASTER_ALLOC / 4;
(rgba_over || d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
}
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
@@ -461,6 +474,8 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]);
/// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color
/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]).
/// 1-bit TIFFs compressed with CCITT Group 3, which the decoder can't read either, become RGBA
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
/// either, become RGBA too, whether they store their channels together or one plane per channel.
/// A TIFF with more strips or tiles than the decoder can count
@@ -495,7 +510,10 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
};
let img = match (f.id == "tiff").then(extra_samples).flatten() {
Some(img) => img,
None => reader.decode().map_err(err)?.to_rgba8(),
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
Some(read) => read.map_err(err)?,
None => reader.decode().map_err(err)?.to_rgba8(),
},
};
as_png(img, ppi)
}
+2
View File
@@ -42,6 +42,8 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>, kin
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(&regions)));
Ok(enc)
}
+16 -9
View File
@@ -8,7 +8,7 @@
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
//! - `screens`: Export for Screens (`document.exportForScreens`, `document.exportSettings`); `zip`
//! packs its files as one download.
//! packs its files as one download, and reads the files of Word documents placed as type.
//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
@@ -45,7 +45,7 @@ mod save;
mod screens;
mod svg;
mod text;
mod zip;
pub mod zip;
use serde_json::{Value, json};
@@ -78,7 +78,8 @@ pub use screens::{
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
};
pub(crate) use screens::{
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings,
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, safe_file_name,
store_settings as store_screen_settings,
};
pub use svg::options_map as svg_options;
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
@@ -141,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
has_doc,
screens::export_for_screens
),
@@ -186,7 +187,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
[],
None,
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\` and `:` in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\`, `:`, `<`, `>`, `\"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
always,
action_batch::action_batch
),
@@ -213,7 +214,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Office Documents…",
["File"],
None,
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height} (pixels); no path → {dataBase64, bytes, width, height}",
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height, warnings} (pixels); no path → {dataBase64, bytes, width, height, warnings}; warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)",
has_doc,
export::export_for_office
),
@@ -710,7 +711,11 @@ pub const TEXT_EXTS: &[&str] = &["txt"];
/// RTF files: File → Place sets them as area type with their formatting.
pub const RTF_EXTS: &[&str] = &["rtf"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`].
/// Word documents: File → Place sets them as area type with their formatting.
pub const DOCX_EXTS: &[&str] = &["docx"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and
/// [`DOCX_EXTS`].
pub const PLACE_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
@@ -741,6 +746,7 @@ pub const PLACE_EXTS: &[&str] = &[
"afpub",
"txt",
"rtf",
"docx",
];
/// One dialog filter per readable format.
@@ -764,13 +770,14 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text
/// and RTF.
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
/// RTF and Word.
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All placeable files", PLACE_EXTS))
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
.chain(std::iter::once(("Text", TEXT_EXTS)))
.chain(std::iter::once(("RTF", RTF_EXTS)))
.chain(std::iter::once(("Word", DOCX_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
+12 -11
View File
@@ -12,7 +12,10 @@
//! (YYYYMMDD), `<time>` (hhmmss), `<YYYY>`, `<MM>`, `<DD>`, `<hh>`, `<mm>`, `<ss>`.
//!
//! A trailing `.ext` (or `.<ext>`, `.<format>`) is dropped: the extension comes from the format.
//! Anything that could start a folder (`/`, `\`, `:`) becomes `-`.
//! The characters Windows does not allow in a file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`,
//! `*` and control characters) become `-`, in the template's text and in what the tokens put in.
use super::screens::file_part;
/// What a template's tokens stand for, for one file.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -50,11 +53,6 @@ fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
})
}
/// Text with `/`, `\` and `:` turned into `-`, so it can't start a folder.
fn clean(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
}
/// Does `template` place the row's suffix itself (so it isn't added at the end)?
pub fn has_suffix_token(template: &str) -> bool {
template.contains("<suffix>")
@@ -72,12 +70,12 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
let mut out = String::with_capacity(t.len() + 16);
let mut rest = t;
while let Some(c) = rest.chars().next() {
// Bracketed tokens; an unknown one stays as written.
// Bracketed tokens; an unknown one stays as written, with `-` for its brackets.
if c == '<'
&& let Some(end) = rest.find('>')
&& let Some(value) = rest.get(1..end).and_then(|name| token(name, p))
{
out.push_str(&clean(&value));
out.push_str(&value);
rest = rest.get(end + 1..).unwrap_or("");
continue;
}
@@ -94,10 +92,10 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
rest = rest.get(pat.len()..).unwrap_or("");
continue;
}
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
out.push(c);
rest = rest.get(c.len_utf8()..).unwrap_or("");
}
out.trim().to_string()
file_part(out.trim())
}
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
@@ -133,7 +131,7 @@ mod tests {
assert_eq!(render("YYYY-MM-DD YYYY", &p), "2026-10-16 2026");
// Words are left alone: only the date patterns and bracketed tokens are replaced.
assert_eq!(render("SUMMER-DD-MM", &p), "SUMMER-DD-MM");
assert_eq!(render("<unknown>-x", &p), "<unknown>-x");
assert_eq!(render("<unknown>-x", &p), "-unknown--x");
assert_eq!(render("logo.<format>", &p), "logo", "the extension comes from the format");
assert!(has_suffix_token("a<suffix>") && !has_suffix_token("a"));
}
@@ -143,6 +141,9 @@ mod tests {
let p = NameParts { name: "../../etc", ..parts() };
let n = render("a/b\\c:<name>", &p);
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
// Nor any other character Windows does not allow in a file name, typed or put in.
let p = NameParts { name: "<a>\"b|c?d*\u{0}\u{1f}", ..parts() };
assert_eq!(render("<name>|?*\u{1}", &p), "-a--b-c-d-------");
// A long counter is padded, a short run is not cut.
assert_eq!(render("########", &NameParts { counter: 123, ..parts() }), "00000123");
assert_eq!(render("#", &NameParts { counter: 12345, ..parts() }), "12345");
+12
View File
@@ -254,9 +254,21 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)))?;
warnings.extend(drawn_warnings);
warnings.extend(overflow_warning(cmd, &full, p)?);
Ok((bytes, warnings))
}
/// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and
/// whose text doesn't fit (see [`crate::cmd::fonts::export_overflow_warning`]): on the pages
/// chosen, leaving out the layers that don't print unless the PDF writes them.
fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result<Option<String>> {
let opts = options(cmd, doc, p)?;
let areas = vectorcraft_pdf::page_areas(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
let set = &opts.settings;
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
Ok(crate::cmd::fonts::export_overflow_warning(doc, printed.as_deref().unwrap_or(&doc.layers), Some(&areas)))
}
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
/// native document with its save options, whose pages may be left blank).
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
+8 -1
View File
@@ -648,7 +648,8 @@ impl SaveJob {
/// Once written: Save and Save As (except to a template) make the file `st`'s own: path,
/// title, format, options, and the snapshot as the saved state (edits made since the job was
/// made keep the document modified).
/// made keep the document modified; changes that are not edits to save don't,
/// [`DocState::same_content`]).
pub fn finish(self, st: &mut DocState) {
let retargets = self.plan.retargets();
if let (true, Some(path)) = (retargets, self.plan.path) {
@@ -660,6 +661,12 @@ impl SaveJob {
if let Some(at) = self.stamped {
if Arc::ptr_eq(&st.doc, &self.source) {
st.doc = self.snapshot.clone();
} else if st.same_content(&self.source) {
// Changed while the save ran only by changes that are not edits to save
// (Puppet Warp pins dropped, Edit Text ended, type laid out again after a font
// scan): with the dates, the document counts as saved.
stamp_save_dates(st, at);
return st.mark_saved();
} else {
// Edited while the save ran: the dates still go on, the edits stay unsaved. The
// undo history holds the document as it was before the dates, so that is the
+54 -16
View File
@@ -21,7 +21,8 @@ use super::encode::single_artboard;
use super::export::{isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names, encode_with_warnings, unique_file_names,
with_black_output, writable, write_file,
};
const C: &str = "document.exportForScreens";
@@ -148,10 +149,29 @@ fn safe_name(s: &str) -> Option<String> {
(!n.trim_matches('.').is_empty()).then_some(n)
}
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch): path separators and
/// `:` become `-`, so the name cannot make a subfolder or point outside the folder the file is written to.
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch, a name pattern's text
/// and the names it puts in): the characters Windows does not allow in a file name (`/`, `\`, `:`,
/// `<`, `>`, `"`, `|`, `?`, `*` and the control characters U+0000 to U+001F,
/// <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>) become `-` on every
/// platform, so the name cannot make a subfolder or point outside the folder the file is written to.
pub(super) fn file_part(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*') || c < ' ' { '-' } else { c }).collect()
}
/// A whole file or folder name (`[email protected]`, a sub-folder) through [`file_part`], without
/// trailing periods and spaces, and with `-` after a Windows device name that is the whole name or
/// comes before its first period: `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9` and `LPT1` to `LPT9`,
/// also with `¹`, `²` or `³` for the digit, in any case (`CON.png` becomes `CON-.png`). The same on
/// every platform.
pub(crate) fn safe_file_name(s: &str) -> String {
let name = file_part(s);
let name = name.trim_end_matches(['.', ' ']);
let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len()));
let upper = base.to_ascii_uppercase();
let numbered = |n: &str| n.chars().count() == 1 && n.chars().all(|c| matches!(c, '1'..='9' | '¹' | '²' | '³'));
let device = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| upper.strip_prefix("COM").or_else(|| upper.strip_prefix("LPT")).is_some_and(numbered);
if device { format!("{base}-{rest}") } else { name.to_string() }
}
/// Keys of a format row that aren't encoder options.
@@ -541,7 +561,8 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
/// so do different files the `name` pattern gives one name.
/// so do different files the `name` pattern gives one name. The reply's `warnings` lists every
/// warning the encoders give for the files, each once.
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
@@ -588,8 +609,18 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let crop = rows.iter().any(|r| r.format.id != "pdf");
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
let mut out = Output::default();
// The encoders' warnings for every file, each once.
let mut warnings: Vec<String> = Vec::new();
// Appearance of Black › Printing / Exporting for RGB raster rows.
let write = |doc: &Document, format: &str, options: &Value| encode(doc, format, &with_black_output(s, options));
let mut write = |doc: &Document, format: &str, options: &Value| -> Result<Vec<u8>> {
let (bytes, notes) = encode_with_warnings(doc, format, &with_black_output(s, options))?;
for w in notes {
if !warnings.contains(&w) {
warnings.push(w);
}
}
Ok(bytes)
};
// A whole-document copy of an artboard, for the formats that write documents to edit.
let mut board_docs: std::collections::HashMap<usize, Arc<Document>> = std::collections::HashMap::new();
let mut files = 0u64;
@@ -615,10 +646,10 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
None => format!("{prefix}{}{}", item.stem, r.suffix),
};
let file = format!("{stem}.{ext}");
let file = safe_file_name(&format!("{stem}.{ext}"));
let sub = match folders {
Folders::None => None,
Folders::Rows => Some(r.folder.clone()),
Folders::Rows => Some(safe_file_name(&r.folder)),
Folders::Format => Some(ext.to_string()),
Folders::Item => Some(item.stem.clone()),
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
@@ -644,20 +675,24 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
if o.zip {
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
let zip_name = format!("{prefix}{filename}.zip");
let zip_name = safe_file_name(&format!("{prefix}{filename}.zip"));
return match folder {
Some(dir) => {
let path = format!("{dir}/{zip_name}");
write_file(&path, &zip)?;
Ok(json!({ "path": path, "bytes": zip.len(), "files": names }))
Ok(json!({ "path": path, "bytes": zip.len(), "files": names, "warnings": warnings }))
}
None => Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names })),
None => Ok(
json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names, "warnings": warnings }),
),
};
}
Ok(match folder {
Some(_) => json!({ "files": out.written }),
Some(_) => json!({ "files": out.written, "warnings": warnings }),
None => {
json!({ "files": out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::<Vec<_>>() })
let files: Vec<Value> =
out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
json!({ "files": files, "warnings": warnings })
}
})
}
@@ -670,8 +705,10 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
};
// Each item's stem follows Windows' file-naming rules, and no two items share one.
let safe = |stems: Vec<String>| distinct_names(stems.iter().map(|s| safe_file_name(s)));
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
let stems = unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())));
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
pieces
.into_iter()
.zip(stems)
@@ -688,7 +725,8 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
(doc.clone(), Rect::ZERO)
};
let name = filename.to_string();
return Ok(vec![Item { name: name.clone(), stem: name, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
let stem = safe_file_name(&name);
return Ok(vec![Item { name, stem, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
}
if let Some(ids) = &o.assets {
let mut pieces = vec![];
@@ -742,7 +780,7 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let stems = artboard_file_names(doc, &boards);
let stems = safe(artboard_file_names(doc, &boards));
Ok(boards
.into_iter()
.zip(stems)
+5
View File
@@ -157,6 +157,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
// that preparation out of the native attachment so missing links remain relinkable.
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
let doc = &*full;
// A chosen artboard's file holds the art over it; otherwise the file holds all of it.
let regions: Option<Vec<vectorcraft_geom::Rect>> =
chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());
let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
let doc = flat.as_ref().unwrap_or(doc);
@@ -184,5 +188,6 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
{
enc.warnings.push(w);
}
enc.warnings.extend(overflow);
Ok(enc)
}
+107
View File
@@ -208,6 +208,113 @@ fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
}
/// A 70 × 7 picture, `true` for black: a white row and a black row (runs past 63 take a make-up
/// code), then runs of several lengths.
fn fax_picture() -> Vec<Vec<bool>> {
(0..7).map(|y| (0..70).map(|x| if y < 2 { y == 1 } else { (x * (y + 2) / 5 + y) % 3 == 0 }).collect()).collect()
}
/// The first `rows` rows of [`fax_picture`] as a TIFF compressed with CCITT Group 3 in one strip:
/// `compression` 2 or 3 with `t4_options`, white is zero, the highest bit of each byte first.
fn group3_tiff(compression: u16, t4_options: u32, rows: usize) -> Vec<u8> {
let rows = &fax_picture()[..rows];
let g =
vectorcraft_testkit::tiff::Group3 { rows, compression, t4_options, photometric: 0, fill_order: 1, rows_per_strip: 7, tile: None, rtc: false };
vectorcraft_testkit::tiff::group3(&g)
}
/// 1-bit TIFFs compressed with CCITT Group 3 open and place with their pixels (#1466): Compression
/// 2 (modified Huffman) and Compression 3 (ITU-T T.4) with one-dimensional rows or two-dimensional
/// ones (T4Options bit 0), with and without fill bits (bit 2) and six EOLs at the end, in one strip,
/// three strips or three 32 × 16 tiles, each byte's highest or lowest bit first (FillOrder 1 and 2),
/// white or black as zero.
#[test]
fn ccitt_group3_tiffs_open_and_place_with_their_pixels() {
let rows = fax_picture();
let mut s = Session::new();
for (compression, t4_options, rtc) in [(2, 0, false), (3, 0, false), (3, 4, true), (3, 1, false), (3, 5, true)] {
for (rows_per_strip, tile) in [(7, None), (3, None), (7, Some((32, 16)))] {
for fill_order in [1, 2] {
for photometric in [0, 1] {
let what = format!(
"Compression {compression}, T4Options {t4_options}, {rows_per_strip} rows a strip, tiles {tile:?}, FillOrder {fill_order}, photometric {photometric}"
);
let g = vectorcraft_testkit::tiff::Group3 {
rows: &rows,
compression,
t4_options,
photometric,
fill_order,
rows_per_strip,
tile,
rtc,
};
let file = vectorcraft_testkit::tiff::group3(&g);
assert!(image::load_from_memory(&file).is_err(), "{what}: the image decoder reads Group 3 now");
// A black run's pixels are 1 bits, which show as black when white is zero.
let want: Vec<u8> = rows.concat().iter().map(|&black| if black == (photometric == 0) { 0 } else { 255 }).collect();
let pixels = |s: &Session| {
let blob = &s.doc().unwrap().doc.images[&image_of(s).key];
image::load_from_memory(&blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_luma8().into_raw()
};
open(&mut s, "fax.tif", &file);
assert_eq!(pixels(&s), want, "{what}: Open");
let mut placed = session(100.0, 100.0, 1);
placed.execute("file.place", &json!({"name": "fax.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
assert_eq!(pixels(&placed), want, "{what}: Place");
}
}
}
}
}
/// A Group 3 TIFF whose pixels would take more than the decoding allowance as RGBA is refused as
/// too large to draw, without a panic at 2^31 × 2^31 pixels too. One whose data ends inside a row,
/// or after a whole row before the strip's last, is refused as damaged, and so is one whose tiles
/// are wider than the allowance.
#[test]
fn ccitt_group3_tiffs_too_large_or_cut_short_are_refused() {
// ImageWidth (the directory's first entry, its value at offset 18), ImageLength (the second, at
// 30) and RowsPerStrip (the eighth, at 102).
for (width, height) in [(70u32, 1u32 << 30), (1 << 31, 1 << 31)] {
let mut big = group3_tiff(3, 1, 7);
big[18..22].copy_from_slice(&width.to_le_bytes());
big[30..34].copy_from_slice(&height.to_le_bytes());
big[102..106].copy_from_slice(&height.to_le_bytes());
let e = raster_image(&big).err().expect("refused");
assert!(e.to_string().contains(&format!("an image of {width} × {height} pixels is too large to draw")), "{e}");
}
let mut short = group3_tiff(3, 0, 7);
short.truncate(short.len() - 6);
let e = raster_image(&short).err().expect("refused");
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
// Compression 2 rows start at a byte, so the strip of the first six rows is where the strip of
// seven starts. StripByteCounts (the ninth entry, its value at offset 114) gives the six rows'.
let (mut seven, six) = (group3_tiff(2, 0, 7), group3_tiff(2, 0, 6));
seven[114..118].copy_from_slice(&six[114..118]);
assert_eq!(seven[30..34], 7u32.to_le_bytes(), "ImageLength");
assert_eq!(seven[78..82], six[78..82], "StripOffsets (the sixth entry, at offset 78)");
let e = raster_image(&seven).err().expect("refused");
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
// A tile wider than the allowance: TileWidth (the eighth entry, its value at offset 102).
let rows = fax_picture();
let g = vectorcraft_testkit::tiff::Group3 {
rows: &rows,
compression: 3,
t4_options: 1,
photometric: 0,
fill_order: 1,
rows_per_strip: 7,
tile: Some((80, 16)),
rtc: false,
};
let mut wide = vectorcraft_testkit::tiff::group3(&g);
assert_eq!(wide[102..106], 80u32.to_le_bytes(), "TileWidth");
wide[102..106].copy_from_slice(&u32::MAX.to_le_bytes());
let e = raster_image(&wide).err().expect("refused");
assert!(e.to_string().contains("could not decode image: arithmetic overflow"), "{e}");
}
#[test]
fn content_beats_a_wrong_extension() {
let png = image_bytes(2, 2, image::ImageFormat::Png);
@@ -303,6 +303,144 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
}
/// `data` as ASCII85, up to the `~>`.
fn ascii85(data: &[u8]) -> String {
let mut out = String::new();
for chunk in data.chunks(4) {
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
let mut d = [0u8; 5];
for c in d.iter_mut().rev() {
*c = (x % 85) as u8 + b'!';
x /= 85;
}
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
}
out.push_str("~>");
out
}
/// Page content drawing one glyph and then `rest` in 10 pt on the baseline at `y`, from x = 14, in
/// rendering mode `mode` (0 fills the type, 1 strokes it). The glyph is code 0x41 of the font `F2`,
/// standing for the "fi" of the story, and `F2` isn't installed and its `ToUnicode` map gives that
/// glyph U+FFFD, so that the PDF part keeps it as outlines; `rest` is in Helvetica (`F1`).
fn half_outlined_line(y: f64, rest: &str, mode: u8) -> String {
format!("{mode} Tr BT /F2 10 Tf 14 {y} Td (A) Tj ET BT /F1 10 Tf 19 {y} Td ({rest}) Tj ET ")
}
/// A `.ai` whose page draws a gray square and `type_content` (see [`half_outlined_line`]), and whose
/// layer `Art` has the square and text objects: the first of story 0, which `story` gives in the
/// text document (a line starting at (14, 65)), then `more` of a story the text document doesn't
/// have. Without `story` the file has no text document.
fn half_outlined_ai(story: Option<&str>, more: usize, type_content: &str) -> Vec<u8> {
let text = |story: u32| format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n;\n");
let texts: String = std::iter::once(text(0)).chain((0..more).map(|_| text(99))).collect();
let mut body =
format!("%AI3_TemplateBox: 100 50 100 50\n{}", ai::layer("Art", &format!("0.5 g\n{}f\n{texts}", ai::rect(120.0, 20.0, 160.0, 60.0))));
if let Some(story) = story {
// The story's one line starts at the canvas center (8191.5, the template box's center) moved
// to (14, 65): its y runs down the page.
let n = story.chars().count();
let document = format!(
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\
/1 << /1 [ << /0 << /0 ({story}\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {} >> ] >> \
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 10.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> >> >> >> /1 {} >> ] >> >> \
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8105.5 8176.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
<< /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 {} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >> ] /2 << /1 12.0 >> >>\n",
n + 1,
n + 1,
n + 1
);
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
body.push_str(&format!("%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n", lines.join("\n")));
}
let data = ai::editing_data(200.0, 100.0, &body);
let content = format!("0.5 g 120 20 40 40 re f 0.2 g 0.2 G {type_content}");
let page = PdfPage {
resources: format!(
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> \
/F2 << /Type /Font /Subtype /Type1 /BaseFont /VectorCraftTestUnnamed /ToUnicode {} 0 R >> >>",
first_extra(1) + 1
),
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
..PdfPage::new(200.0, 100.0, &content)
};
let cmap = "begincmap\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n1 beginbfchar\n<41> <FFFD>\nendbfchar\nendcmap\n";
pdf_with(
&[page],
&[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()), &format!("<< /Length {} >>\nstream\n{cmap}endstream", cmap.len())],
None,
)
}
fn outlines_notes(r: &Value) -> Vec<String> {
let notes = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str);
notes.filter(|w| vectorcraft_pdf::is_outlines_note(w)).map(String::from).collect()
}
/// The notes the PDF part of [`half_outlined_ai`] reads with: its glyph of U+FFFD, kept as outlines.
fn pdf_part_outlines_notes() -> [String; 2] {
[format!("text in Vector Craft Test Unnamed {}", vectorcraft_pdf::UNNAMED_CHARACTERS_NOTE), vectorcraft_pdf::OUTLINED_TEXT_NOTE.into()]
}
/// The note on text objects that hold the page's type without some of its characters, for `n` of
/// them.
fn without_outlines_note(n: usize) -> Value {
json!(format!("{n} text objects hold their type as the page draws it, without the characters the page has only as outlines"))
}
/// The texts in the first layer.
fn texts(s: &Session) -> Vec<String> {
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect()
}
/// The note that glyphs were kept as outlines is dropped when the type comes from the editing data's
/// text document, and stays with the PDF part alone.
#[test]
fn type_from_the_text_document_opens_without_the_pdf_parts_note_on_outlines() {
let file = half_outlined_ai(Some("first draft"), 0, &half_outlined_line(65.0, "rst draft", 0));
let mut s = Session::new();
let r = open(&mut s, "draft.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(texts(&s), ["first draft"], "{r}");
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
let r = open(&mut s, "draft.ai", &file, json!({"editingData": false})).unwrap();
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
// A second text object that takes the page's type, away from the glyph: no note on it.
let page = half_outlined_line(65.0, "rst draft", 0) + "BT /F1 10 Tf 14 30 Td (notes) Tj ET";
let r = open(&mut s, "notes.ai", &half_outlined_ai(Some("first draft"), 1, &page), json!({})).unwrap();
assert_eq!(texts(&s), ["first draft", "notes"], "{r}");
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
}
/// A text object that takes the page's type lacks the glyphs next to it that the page kept as
/// outlines: a note counts such objects, in place of the PDF part's notes. Stroked glyphs that
/// come in with the stroked type keep the PDF part's notes.
#[test]
fn type_from_the_page_opens_with_a_note_on_the_characters_it_has_only_as_outlines() {
let mut s = Session::new();
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 0)), json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(texts(&s), ["rst draft"], "{r}");
assert!(!vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
// Stroked: the glyph comes in with the type.
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 1)), json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert!(vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
assert!(!r["warnings"].to_string().contains("without the characters"), "{r}");
// A stroked line whose glyph comes in, and a filled one whose glyph doesn't.
let page = half_outlined_line(65.0, "rst draft", 1) + &half_outlined_line(30.0, "nd more", 0);
let r = open(&mut s, "both.ai", &half_outlined_ai(None, 1, &page), json!({})).unwrap();
assert_eq!(texts(&s), ["rst draft", "nd more"], "{r}");
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
}
/// A `.ai` saved in units of 10 points: its PDF part's page is 200 × 100 with `/UserUnit 10`, and
/// its editing data has the artboard and a red rectangle in the same units. It opens at 2000 × 1000
/// pt, from its editing data and from its PDF part alone, with the rectangle ten times as large,
@@ -105,6 +105,17 @@ fn the_counter_counts_items_or_files_from_any_start() {
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
}
/// Export for Screens writes the characters Windows does not allow in a file name (`<`, `>`, `"`,
/// `|`, `?`, `*`, control characters) as `-` in a pattern's text and in the names its tokens stand
/// for, on every platform.
#[test]
fn a_pattern_writes_the_characters_windows_does_not_allow_as_dashes() {
let mut s = session(1);
s.execute("artboard.setProps", &json!({"index": 0, "name": "a|b?\"c\u{7}"})).unwrap();
let r = export(&mut s, json!({"name": "<name>*<x>", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["a-b--c---x-.svg"]);
}
#[test]
fn layers_and_the_selection_are_exported_by_name() {
let mut s = session(1);
@@ -110,6 +110,42 @@ fn density_buckets_go_into_sub_folders() {
assert_eq!(names(&r), ["Artboard-2-..-x-..-y.svg", "Artboard-2-ok.png"]);
}
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name
/// (<https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>): Export for Screens
/// writes them as `-` in a prefix and a suffix, on every platform.
#[test]
fn a_prefix_or_suffix_writes_the_characters_windows_does_not_allow_as_dashes() {
let mut s = session(1);
let r = export(&mut s, json!({"prefix": "a<b>c\"", "formats": [{"format": "svg", "suffix": "|d?e*f\u{1}\t"}]}));
assert_eq!(names(&r), ["a-b-c-Artboard-1-d-e-f--.svg"]);
}
/// A file or folder named after a Windows device (`CON`, `NUL`, `COM1`…, alone or before an
/// extension) gets a `-` after the device name, and a folder loses a trailing period, on every
/// platform.
#[test]
fn device_names_and_trailing_periods_follow_windows_naming_rules() {
let mut s = session(3);
for (index, name) in ["CON", "CON-", "lpt1"].iter().enumerate() {
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
}
let r = export(&mut s, json!({"formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["CON-.svg", "CON--2.svg", "lpt1-.svg"]);
let r =
export(&mut s, json!({"range": "1", "subfolders": true, "formats": [{"format": "svg", "folder": "aux"}, {"format": "pdf", "folder": "x."}]}));
assert_eq!(names(&r), ["aux-/CON-.svg", "x/CON-.pdf"]);
let r = export(&mut s, json!({"range": "3", "folders": "item", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["lpt1-/lpt1-.svg"]);
// A name pattern, with an extension after the device name, and the document's own name.
let r = export(&mut s, json!({"range": "1", "name": "<name>.tar", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["CON-.tar.svg"]);
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap();
let r = export(&mut s, json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}));
assert_eq!(names(&r), ["nul-/nul-.pdf"]);
let r = s.execute("document.exportForScreens", &json!({"zip": true, "formats": [{"format": "svg"}]})).unwrap();
assert_eq!(r["name"], "nul-.zip");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn sub_folders_are_made_in_the_export_folder() {
@@ -164,6 +200,45 @@ fn a_zip_is_written_into_the_folder() {
let _ = std::fs::remove_dir_all(dir);
}
/// The warnings in `r` that contain `text`.
fn warned<'a>(r: &'a Value, text: &str) -> Vec<&'a str> {
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains(text)).collect()
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn every_reply_says_once_when_an_image_could_not_be_decoded() {
let mut s = session(1);
let rows = json!([{"format": "png"}, {"format": "jpg", "scale": "2x"}]);
assert_eq!(export(&mut s, json!({ "formats": rows }))["warnings"], json!([]));
// The renderer can't decode the placed image and draws nothing in its place.
crate::tests_menucmds::place_undecodable_png(&mut s);
let dir = std::env::temp_dir().join(format!("vc-screens-undecoded-{}", std::process::id()));
let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
for p in [
json!({ "formats": rows }),
json!({ "formats": rows, "zip": true }),
json!({ "formats": rows, "folder": folder }),
json!({ "formats": rows, "folder": folder, "zip": true }),
json!({ "formats": rows, "fullDocument": true }),
] {
let r = export(&mut s, p.clone());
assert_eq!(warned(&r, "couldn't be decoded").len(), 1, "{p}: {}", r["warnings"]);
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "the files are still written");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn type_in_a_missing_family_is_named_once_for_every_format() {
let mut s = session(2);
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
let r = export(&mut s, json!({"formats": [{"format": "png"}, {"format": "pdf"}, {"format": "eps"}]}));
let fallback = warned(&r, "fallback font");
assert_eq!(fallback.len(), 1, "{}", r["warnings"]);
assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}");
}
#[test]
fn full_document_gives_one_file_per_format() {
let mut s = session(3);
@@ -85,3 +85,19 @@ fn save_for_office_writes_a_png_at_the_resolution() {
let spec = crate::cmd::find_command("document.exportForOffice").unwrap();
assert_eq!((spec.label, spec.menu), ("Save for Office Documents…", &["File"][..]));
}
#[test]
fn save_for_office_says_when_an_image_or_a_font_is_missing() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
assert_eq!(s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap()["warnings"], json!([]));
// The renderer can't decode the placed image, and the type's family isn't installed.
crate::tests_menucmds::place_undecodable_png(&mut s);
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
let r = s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap();
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings.contains(&"an image couldn't be decoded and is missing from the picture"), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("fallback font") && w.contains("Nonexistent Sans Probe")), "{warnings:?}");
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(200), Some(100)), "the PNG is still written");
}
+144
View File
@@ -1,5 +1,10 @@
//! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are
//! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read.
//!
//! [`Archive`] reads the files of a ZIP archive, stored or deflated, as PKWARE's APPNOTE.TXT
//! describes the format (<https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT>).
use std::io::Read as _;
use flate2::Crc;
@@ -65,6 +70,145 @@ pub fn store<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result<Vec<u8>,
Ok(out)
}
/// Does `bytes` start with a ZIP archive's first local file header?
pub fn is_zip(bytes: &[u8]) -> bool {
bytes.starts_with(&0x0403_4b50u32.to_le_bytes())
}
/// Why [`Archive`] can't read an archive or one of its files.
#[derive(Debug, PartialEq)]
pub enum ReadError {
/// Not a ZIP archive: no end of central directory record.
NotZip,
/// More files than the reader was asked to accept.
TooManyFiles,
/// The archive needs the 64-bit (ZIP64) records, which the reader does not read.
Zip64,
/// The file is encrypted.
Encrypted(String),
/// The file is compressed with a method other than stored (0) or deflated (8).
Method(String, u16),
/// The file unpacks to more bytes than the reader was asked to accept.
TooLarge(String),
/// The archive (empty name) or the file is truncated, inconsistent or fails its CRC.
Damaged(String),
}
/// One file of an archive, as its central directory record describes it.
struct Entry {
name: String,
flags: u16,
method: u16,
crc: u32,
packed: usize,
size: usize,
local: usize,
}
/// A ZIP archive's files, listed from its central directory.
pub struct Archive<'a> {
zip: &'a [u8],
entries: Vec<Entry>,
}
impl<'a> Archive<'a> {
/// The archive `zip`, with at most `max_files` files.
pub fn open(zip: &'a [u8], max_files: usize) -> Result<Self, ReadError> {
let damaged = || ReadError::Damaged(String::new());
let u16_at = |at: usize| at.checked_add(2).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
let u32_at = |at: usize| at.checked_add(4).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
// The end record is 22 bytes followed by a comment of at most 65535 bytes: the signature
// whose comment ends the archive, else the last signature (the archive has bytes after it).
let last = zip.len().checked_sub(22).ok_or(ReadError::NotZip)?;
let candidates = || (last.saturating_sub(0xFFFF)..=last).rev().filter(|&at| u32_at(at) == Some(0x0605_4b50));
let end =
candidates().find(|&at| u16_at(at + 20).map(usize::from) == Some(last - at)).or_else(|| candidates().next()).ok_or(ReadError::NotZip)?;
let count = u16_at(end + 10).ok_or_else(damaged)?;
let start = u32_at(end + 16).ok_or_else(damaged)?;
if count == 0xFFFF || start == 0xFFFF_FFFF || u32_at(end + 12) == Some(0xFFFF_FFFF) {
return Err(ReadError::Zip64);
}
let count = usize::from(count);
if count > max_files {
return Err(ReadError::TooManyFiles);
}
let mut at = start as usize;
let mut entries = Vec::with_capacity(count);
for _ in 0..count {
if u32_at(at) != Some(0x0201_4b50) {
return Err(damaged());
}
let short = |off: usize| at.checked_add(off).and_then(u16_at).ok_or_else(damaged);
let long = |off: usize| at.checked_add(off).and_then(u32_at).ok_or_else(damaged);
let (name_len, extra, comment) = (usize::from(short(28)?), usize::from(short(30)?), usize::from(short(32)?));
let name_at = at.checked_add(46).ok_or_else(damaged)?;
let name = name_at.checked_add(name_len).and_then(|end| zip.get(name_at..end)).ok_or_else(damaged)?;
entries.push(Entry {
name: String::from_utf8_lossy(name).into_owned(),
flags: short(8)?,
method: short(10)?,
crc: long(16)?,
packed: long(20)? as usize,
size: long(24)? as usize,
local: long(42)? as usize,
});
at = name_at.checked_add(name_len + extra + comment).ok_or_else(damaged)?;
}
Ok(Self { zip, entries })
}
/// The file `name`, matched in any ASCII case.
fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.name.eq_ignore_ascii_case(name))
}
/// Does the archive hold the file `name` (in any ASCII case)?
pub fn contains(&self, name: &str) -> bool {
self.entry(name).is_some()
}
/// The bytes of the file `name` (matched in any ASCII case; None: the archive has no such
/// file), when it unpacks to at most `max` bytes and passes its CRC.
pub fn read(&self, name: &str, max: usize) -> Result<Option<Vec<u8>>, ReadError> {
let Some(e) = self.entry(name) else { return Ok(None) };
let damaged = || ReadError::Damaged(e.name.clone());
if e.flags & 1 != 0 {
return Err(ReadError::Encrypted(e.name.clone()));
}
if e.size > max {
return Err(ReadError::TooLarge(e.name.clone()));
}
// The local header: its signature, then its name and extra field lengths at 26 and 28.
let header = e.local.checked_add(30).and_then(|end| self.zip.get(e.local..end)).ok_or_else(damaged)?;
let (Some(sig), Some(name_len), Some(extra)) = (header.get(..4), header.get(26..28), header.get(28..30)) else { return Err(damaged()) };
if sig != 0x0403_4b50u32.to_le_bytes() {
return Err(damaged());
}
let len = |b: &[u8]| b.try_into().map(|b| usize::from(u16::from_le_bytes(b))).map_err(|_| damaged());
let start = e.local + 30 + len(name_len)? + len(extra)?;
let data = start.checked_add(e.packed).and_then(|end| self.zip.get(start..end)).ok_or_else(damaged)?;
let bytes = match e.method {
0 if e.packed != e.size => return Err(damaged()),
0 => data.to_vec(),
8 => {
let mut out = Vec::with_capacity(e.size);
flate2::read::DeflateDecoder::new(data).take((max as u64).saturating_add(1)).read_to_end(&mut out).map_err(|_| damaged())?;
if out.len() > max {
return Err(ReadError::TooLarge(e.name.clone()));
}
out
}
m => return Err(ReadError::Method(e.name.clone(), m)),
};
let mut crc = Crc::new();
crc.update(&bytes);
if bytes.len() != e.size || crc.sum() != e.crc {
return Err(damaged());
}
Ok(Some(bytes))
}
}
/// The entries of a store-only archive written by [`store`] (read through its central directory,
/// checking every CRC) → `(name, bytes)`.
#[cfg(test)]
+8 -2
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Flatten Transparency…",
["Object"],
None,
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}",
"{preset?: \"high\"|\"medium\"|\"low\" or a saved preset's name (see flattener.presets.list; default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?|id?} overlapping transparent objects become one group of flat-colour regions (CMYK colours composited ink by ink in CMYK documents), plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
has_doc,
flatten
),
@@ -259,13 +259,19 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into()));
}
let src = s.doc()?.doc.clone();
let failed = vectorcraft_render::decode_failures();
let plan = plan(&src, &roots, &o, true)?;
let warning = super::rasterfx::undecoded_warning(failed);
let options = serde_json::to_value(&o).unwrap_or_default();
if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) {
return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": 0, "vector": 0, "options": options }));
}
let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| apply(d, sel, plan, &o))?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options }))
let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": rasterized, "vector": vector, "options": options });
if let Some(w) = warning {
out["warning"] = json!(w);
}
Ok(out)
}
// ---------- presets ----------
+40 -12
View File
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Font List",
[],
None,
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), each the family's English (canonical) name for `text.setStyle` and documents (without the system's hidden families, whose names start with a dot; they still resolve by name). Preferences › Type › Show Font Names in English only changes the UI menu labels, not this list; with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
"{family?, style?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), each the family's English (canonical) name for `text.setStyle` and documents (without the system's hidden families, whose names start with a dot; they still resolve by name). Preferences › Type › Show Font Names in English only changes the UI menu labels, not this list; with family: {family, styles: [names] (upright styles by weight, then italics; an error when the family isn't available), axes: [{tag, name, min, default, max, value, hidden}] (a variable font's variation axes, the Character panel's Variable Font sliders, with `value` the setting of `style`, else of the first style; empty for a static font; `hidden`: the font asks for the axis not to be shown)}",
always,
font_list
),
@@ -136,6 +136,19 @@ pub(super) fn printed_type(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -
(walk.fonts, walk.overflow)
}
/// The warning of an export that draws `layers` of `d` over `areas` (document space; `None`: the
/// whole of them) about the area type and type on a path that it draws and whose text doesn't fit,
/// found as [`printed_type`] finds them for Print: visible objects of the document that aren't on
/// a template layer.
pub(super) fn export_overflow_warning(d: &Document, layers: &[Arc<Node>], areas: Option<&[Rect]>) -> Option<String> {
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
for l in layers {
walk.node(l, areas);
}
let overflow: Vec<NodeId> = walk.overflow.into_iter().collect();
super::typecmd::export_overflow_warning(&overflow)
}
/// The walk of [`printed_type`].
struct Printed<'a> {
d: &'a Document,
@@ -395,8 +408,9 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
.unwrap_or_else(|| r.style.font_style.clone());
r.style.font_family = tf.clone();
r.style.font_style = style;
// Another font: none of the old one's versions.
// Another font: none of the old one's versions or Variable Font settings.
r.style.font_version = None;
r.style.font_axes.clear();
changed = true;
n += 1;
}
@@ -440,7 +454,23 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
}
// The name as the font spells it.
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
Ok(json!({ "family": name, "styles": db.styles(family) }))
let styles = db.styles(family);
let style = str_param(p, "style").map(str::to_string).or_else(|| styles.first().cloned()).unwrap_or_else(|| "Regular".into());
let axes: Vec<Value> = db
.face(family, &style)
.map(|face| {
let axes = face.axes();
axes.iter()
.map(|a| {
json!({
"tag": String::from_utf8_lossy(&a.tag), "name": a.name, "min": a.min, "default": a.default, "max": a.max,
"value": face.axis_value(a.tag).unwrap_or(a.default), "hidden": a.hidden,
})
})
.collect()
})
.unwrap_or_default();
Ok(json!({ "family": name, "styles": styles, "axes": axes }))
}
None => Ok(json!({ "families": *db.menu_family_list() })),
}
@@ -462,16 +492,14 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
}
});
if !texts.is_empty() {
let saved = !d.is_dirty();
let doc = std::sync::Arc::make_mut(&mut d.doc);
for id in texts {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
refresh_bounds(t);
d.change_unsaved(|doc| {
let doc = std::sync::Arc::make_mut(doc);
for id in texts {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
refresh_bounds(t);
}
}
}
if saved {
d.mark_saved();
}
});
}
d.revision += 1;
}
+8 -2
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Vector Halftone…",
["Object"],
None,
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots}",
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
has_selection,
halftone
)]
@@ -185,11 +185,13 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
let mut r = vectorcraft_render::Renderer::new();
let (w, h) = vectorcraft_render::region_pixels(region, scale);
let (w, h) = (w as usize, h as usize);
let failed = vectorcraft_render::decode_failures();
let px = if mono {
r.render_region(&tmp, region, scale, true).pixels
} else {
r.render_region_inks(&tmp, region, scale, &vectorcraft_render::RenderOptions { skip_templates: true, ..Default::default() })
};
let warning = super::rasterfx::undecoded_warning(failed);
if px.len() < w * h * 4 {
return Err(EngineError::Other("the art could not be sampled".into()));
}
@@ -241,7 +243,11 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": id.0, "dots": dots }))
let mut out = json!({ "id": id.0, "dots": dots });
if let Some(w) = warning {
out["warning"] = json!(w);
}
Ok(out)
}
/// The dots of one screen over `bounds` as one path, with their count.
+5 -3
View File
@@ -810,7 +810,8 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_from(p, def);
let idx = s.edit("New Artboard", |d, _| {
let id = d.next_artboard_id();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Artboard {}", d.artboards.len() + 1));
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false });
Ok(d.artboards.len() - 1)
})?;
@@ -864,8 +865,9 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
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") {
a.name = n.to_string();
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() };
}
let id = a.id;
if let Some(xf) = transform {
+4 -3
View File
@@ -391,11 +391,12 @@ fn remove_file(_: &str) -> Result<()> {
}
/// Library `name` as a file name without its extension: letters, digits, spaces, `-` and `_`
/// kept, other characters as `-` ("Library" when nothing is left).
/// kept, other characters as `-`, and a `-` after a Windows device name such as `CON` or `COM1`
/// (`fileio::safe_file_name`); "Library" when nothing is left.
pub fn file_stem_for(name: &str) -> String {
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
let safe = safe.trim();
if safe.is_empty() { "Library".to_string() } else { safe.to_string() }
if safe.is_empty() { "Library".to_string() } else { super::fileio::safe_file_name(safe) }
}
fn stem_of(path: &str) -> String {
@@ -884,7 +885,7 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
}
Kind::CharStyle | Kind::ParaStyle => {
let para = kind == Kind::ParaStyle;
let name = data.get("name").and_then(Value::as_str).unwrap_or("Style").to_string();
let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string();
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
let doc = &s.doc()?.doc;
// The document's style of that name when it is the same; else a new one.
+108 -18
View File
@@ -14,7 +14,7 @@ use vectorcraft_doc::live::{
PreserveShape, Spine,
};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, PathData, Point, Vec2};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
use super::edit::selected_roots;
use super::expand::gradient_sampler;
@@ -96,6 +96,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
spine_move_anchor
),
cmd!(
"object.blend.spine.convertAnchor",
"Convert Anchor Point",
[],
None,
"{id? (default: the selected blend), anchor: index, to: smooth|corner, x?, y?} convert a blend spine point: dragging a corner creates symmetric smooth handles, clicking a point with handles retracts both. Without x/y, smooth uses the neighbouring spine points",
has_doc,
spine_convert_anchor
),
cmd!(
"object.blend.spine.reshapeSegment",
"Reshape Spine Segment",
@@ -238,7 +247,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Gradient Mesh…",
["Object"],
None,
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}",
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y], columns?: as cols} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point); an embedded image becomes a mesh over its rectangle (its transform kept) whose points take the average colour and opacity of the picture around them (a linked image is refused: embed it first) → {ids, index?}",
has_selection,
mesh_create
),
@@ -402,16 +411,20 @@ const DEFAULT_DISTANCE: f64 = 10.0;
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
let mode = str_param(p, "spacing");
let value = num("value");
let sp = match mode {
Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor,
Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(DEFAULT_STEPS as f64) as u32),
Some("steps") => match value {
Some(v) => steps("value", v)?,
None => steps("steps", num("steps").unwrap_or(DEFAULT_STEPS as f64))?,
},
Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)),
Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))),
None => {
if let Some(n) = num("steps") {
BlendSpacing::Steps(n as u32)
steps("steps", n)?
} else if let Some(d) = num("distance") {
BlendSpacing::Distance(d)
} else if bool_or(p, "smooth", false) {
@@ -649,6 +662,37 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.convertAnchor";
let index = index_param(p, "anchor", C)?;
let smooth = match str_param(p, "to") {
Some("smooth") => true,
Some("corner") => false,
_ => return Err(bad(C, "to must be smooth or corner")),
};
let target = match (p.get("x"), p.get("y")) {
(None, None) => None,
(Some(_), Some(_)) => Some(finite_point(p, C)?),
_ => return Err(bad(C, "x and y must be given together")),
};
edit_spine(s, p, C, "Convert Anchor Point", |sp, _, _| {
if smooth && target.is_none() {
if index >= sp.anchors.len() {
return Err(bad(C, "no such spine point"));
}
vectorcraft_tools::draw2::curvature_smooth(sp, index);
return Ok(());
}
let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?;
match target {
Some(q) if smooth && q.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, q),
_ => a.retract(),
}
Ok(())
})?;
ok()
}
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.reshapeSegment";
let seg = index_param(p, "segment", C)?;
@@ -959,14 +1003,15 @@ fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, f
Node::new(id, NodeKind::Envelope { content, kind: Box::new(kind), fidelity, editing: false, options, frame: Box::new(Affine::IDENTITY) })
}
/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`.
/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`.
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
let rows = f64_or(p, "rows", default.0 as f64);
let cols = f64_or(p, "cols", f64_or(p, "columns", default.1 as f64));
let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" };
let cols = f64_or(p, cols_key, default.1 as f64);
if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) {
return Err(bad(cmd, "rows and cols must be 1..50"));
}
Ok((rows as u32, cols as u32))
Ok((whole(rows, "rows", cmd)? as u32, whole(cols, cols_key, cmd)? as u32))
}
fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1303,7 +1348,7 @@ fn env_edit_contents(s: &mut Session, p: &Value) -> Result<Value> {
fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.setMeshPoint";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let index = f64_req(p, "index", C)? as usize;
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = handle_param(p, C)?;
s.edit("Move Envelope Point", |d, _| {
@@ -1348,18 +1393,33 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| {
let mut out = vec![];
let mut index = None;
let mut refused = None;
for id in &roots {
let Some(n) = d.node(*id).cloned() else { continue };
let Some(path) = outline_of(&n) else { continue };
let m = match n.appearance.fill_paint() {
// The points take the colours the gradient paints there.
Paint::Gradient(g) => {
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
let m = if let NodeKind::Image(im) = &n.kind {
// An embedded image: the points take the picture's colours there.
match image_mesh(d, im, rows, cols) {
Ok(mut m) => {
m.highlight(app, highlight);
m
})
Some(m)
}
Err(why) => {
refused.get_or_insert(why);
continue;
}
}
} else {
let Some(path) = outline_of(&n) else { continue };
match n.appearance.fill_paint() {
// The points take the colours the gradient paints there.
Paint::Gradient(g) => {
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
m.highlight(app, highlight);
m
})
}
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
}
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
};
let Some(mut m) = m else { continue };
if let Some(q) = at {
@@ -1375,7 +1435,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
out.push(*id);
}
if out.is_empty() {
return Err(bad(C, "select a path or compound path"));
return Err(bad(C, refused.unwrap_or("select a path, a compound path or an embedded image")));
}
sel.set(out.iter().copied());
Ok((out, index))
@@ -1387,6 +1447,31 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// Create Gradient Mesh on an embedded image: a `rows` × `cols` mesh over the picture's rectangle
/// (placed with the image's transform), each point taking the average colour and opacity of the
/// area around it. Linked images are refused: the documentation allows embedded ones only.
fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: u32) -> std::result::Result<GradientMesh, &'static str> {
if im.link.is_some() {
return Err("a linked image can't become a gradient mesh: embed it first");
}
let xf = im.xf.as_coeffs();
if im.width == 0 || im.height == 0 || !xf.iter().all(|k| k.is_finite()) {
return Err("the image has no usable size or position");
}
let samples = d.images.get(&im.key).and_then(|b| b.grid_samples(rows, cols)).ok_or("the image's pixels can't be read")?;
let (w, h) = (im.width as f64, im.height as f64);
let at = |u: f64, v: f64| im.xf * Point::new(u * w, v * h);
if ![at(0.0, 0.0), at(1.0, 0.0), at(0.0, 1.0), at(1.0, 1.0)].iter().all(|p| p.x.abs() < 1e15 && p.y.abs() < 1e15) {
return Err("the image has no usable size or position");
}
let cols_n = cols as usize + 1;
let steps = |n: u32| (0..=n).map(|i| i as f64 / n as f64).collect::<Vec<_>>();
Ok(GradientMesh::from_grid(&steps(cols), &steps(rows), &at, &|r, c, _| {
let [pr, pg, pb, pa] = samples.get(r * cols_n + c).copied().unwrap_or([0; 4]);
(Color::rgb8(pr, pg, pb), pa as f32 / 255.0)
}))
}
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
/// envelope's grid too (its points and handles are written back: editing fills in the handles).
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
@@ -1426,7 +1511,12 @@ fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
if i < 0.0 {
return Err(bad(cmd, "index must be ≥ 0"));
}
Ok(i as usize)
Ok(whole(i, key, cmd)? as usize)
}
/// `x`, the value of `key`, when it is a whole number, and an error otherwise.
fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
if x.fract() == 0.0 { Ok(x) } else { Err(bad(cmd, format!("`{key}` must be a whole number"))) }
}
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
+137 -58
View File
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rasterize…",
["Object"],
None,
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height}",
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
has_selection,
rasterize
),
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Object Mosaic…",
["Object"],
None,
"{columns=10, rows=10, width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
"{columns=10, rows=10 (whole numbers, kept within 1..1000), width?, height? (new size in pt; default the image's), spacingX=0, spacingY=0 (pt between tiles), gray?: bool, deleteRaster?: bool} a group of rectangles coloured by the selected image's average tile colours → {id, tiles}",
has_image,
object_mosaic
),
@@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Crop Image",
["Object"],
None,
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim)}",
"{rect?: [x, y, width, height] | trim?: true (to the pixels that aren't fully transparent, pixel for pixel; an image with none to cut is left as it is, trimmed: false)} crop the selected image (default: to the artboard it sits on) → {id, width, height, trimmed? (with trim), warning? (without trim: the image couldn't be decoded, as object.rasterize says)}",
has_image,
crop_image
),
@@ -168,6 +168,20 @@ fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// The whole number `p` gives for `key`, written `4` or `4.0`, or `default` when `p` gives none or null. A value that
/// is not a number gives the [`check_numbers`] error, and a fraction or a negative number gives
/// "`{key}` must be a whole number".
fn whole_or(p: &Value, key: &str, default: u64, cmd: &str) -> Result<u64> {
check_numbers(p, &[key], cmd)?;
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(default) };
match (v.as_u64(), v.as_f64()) {
(Some(n), _) => Ok(n),
// A whole number past `u64::MAX` saturates to it.
(None, Some(x)) if x >= 0.0 && x.fract() == 0.0 => Ok(x as u64),
_ => Err(bad(cmd, format!("`{key}` must be a whole number"))),
}
}
/// Run a command's implementation without the enablement check or journaling.
pub(crate) fn run_raw(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
let c = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
@@ -210,8 +224,9 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
fn above(s: &mut Session, lock: bool) -> Result<Value> {
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let symbols = d.symbol_boxes();
// `selected` holds each selected object's visual bounds, by layer, with the index there of its
// ancestor directly inside the layer.
// ancestor directly inside the layer. Symbol instances are measured by their art.
let mut selected: BTreeMap<NodeId, Vec<(usize, Rect)>> = BTreeMap::new();
for id in &roots {
let Some(layer) = d.layer_of(*id) else { continue };
@@ -219,7 +234,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
let anc = d.ancestry(*id).unwrap_or_default();
let Some(pos) = anc.iter().position(|a| *a == layer) else { continue };
let top = anc.get(pos + 1).copied().unwrap_or(*id);
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue };
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds_with(&symbols)) else { continue };
let Some((_, idx, _)) = d.position(top) else { continue };
selected.entry(layer).or_default().push((idx, b));
}
@@ -239,7 +254,7 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
if under.is_empty() || roots.contains(&n.id) {
continue;
}
if n.visual_bounds().is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
if n.visual_bounds_with(&symbols).is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
hits.insert(n.id);
}
}
@@ -335,13 +350,13 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
let rx = bool_or(p, "reflectX", false);
let ry = bool_or(p, "reflectY", false);
let random = bool_or(p, "random", false);
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize;
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
let copy = bool_or(p, "copy", false);
let objects = super::object::transforms_objects(p);
if sh == 0.0 || sv == 0.0 {
return Err(bad("object.transformEach", "scale must be non-zero"));
}
let mut rng = Rng(p.get("seed").and_then(Value::as_u64).unwrap_or(0x9E37_79B9_7F4A_7C15).max(1));
let mut rng = Rng(whole_or(p, "seed", 0x9E37_79B9_7F4A_7C15, "object.transformEach")?.max(1));
let mut sc = if sh != 100.0 || sv != 100.0 { super::object::scaling(s, p) } else { Default::default() };
sc.patterns = super::object::transform_patterns(s, p, &roots)?;
// With no object to transform, or without a copy when both scales are 100% and there is no
@@ -352,8 +367,11 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
}
let ids = s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() };
for id in &targets {
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
// Measure each object's geometric bounds first, with symbol instances measured by their art.
let symbols = d.symbol_boxes();
let boxes: Vec<Option<Rect>> = targets.iter().map(|id| d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols))).collect();
for (id, b) in targets.iter().zip(boxes) {
let Some(b) = b else { continue };
let (mut k_sh, mut k_sv, mut k_mh, mut k_mv, mut k_rot) = (sh, sv, mh, mv, rot);
if random {
k_sh = 100.0 + (sh - 100.0) * rng.next();
@@ -408,38 +426,71 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String {
}
}
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`.
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`, with the note that an
/// image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]).
fn render_png(
doc: &vectorcraft_doc::Document,
region: Rect,
scale: f64,
opts: &vectorcraft_render::RenderOptions,
color: RasterColorModel,
) -> Result<(Vec<u8>, u32, u32)> {
) -> Result<(Vec<u8>, u32, u32, Option<&'static str>)> {
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
}
let mut r = vectorcraft_render::Renderer::new();
let failed = vectorcraft_render::decode_failures();
let mut img = r.render_region_with(doc, region, scale, opts);
let warning = super::rasterfx::undecoded_warning(failed);
RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels);
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height))
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height, warning))
}
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.rasterize";
let roots = selected_roots(s)?;
let st = s.doc()?;
let ppi = f64_or(p, "ppi", st.doc.raster_effects_ppi);
/// The image Object › Rasterize makes of some art, before it goes in a document
/// ([`rasterize_art`]).
pub(crate) struct Rasterized {
png: Vec<u8>,
width: u32,
height: u32,
/// Image pixels → document.
xf: Affine,
/// The art's outline, with Create Clipping Mask.
clip: Option<(PathData, vectorcraft_geom::FillRule)>,
/// The note that an image in the art couldn't be decoded ([`render_png`]).
warning: Option<&'static str>,
}
impl Rasterized {
/// The image as a new object of `d` (its pixels in `d.images`), in a clip group with the art's
/// outline when it has one.
pub(crate) fn into_node(self, d: &mut vectorcraft_doc::Document) -> Node {
let key = unique_key(d, "raster");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png));
let id = d.alloc_id();
let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default() };
let node = Node::new(id, NodeKind::Image(Box::new(image)));
match self.clip {
Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node),
None => node,
}
}
}
/// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels.
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> {
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
if !(1.0..=2400.0).contains(&ppi) {
return Err(bad(C, "ppi must be between 1 and 2400"));
return Err(bad(cmd, "ppi must be between 1 and 2400"));
}
// The document's raster effects settings, changed by the params.
let mut look = st.doc.raster_effects.clone();
let mut look = doc.raster_effects.clone();
let mut background = (look.background == vectorcraft_doc::Background::White).then_some([255; 3]);
let mut anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
for (k, v) in p.as_object().into_iter().flatten().filter(|(_, v)| !v.is_null()) {
match k.as_str() {
"background" => background = fileio::background(v).map_err(|e| bad(C, e))?,
"background" => background = fileio::background(v).map_err(|e| bad(cmd, e))?,
"antiAlias" => {
anti_alias = match v {
Value::Bool(b) => {
@@ -449,55 +500,74 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
AntiAlias::None
}
}
v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(C, e))?,
v => fileio::anti_alias(v.as_str().unwrap_or_default()).map_err(|e| bad(cmd, e))?,
}
}
"padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, C, k)?,
"colorModel" => look.color_model = super::rasterfx::color_model(v, st.doc.color_mode, C)?,
"clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(C, "clippingMask must be true or false"))?,
"padding" | "addAround" => look.add_around = super::rasterfx::add_around(v, cmd, k)?,
"colorModel" => look.color_model = super::rasterfx::color_model(v, doc.color_mode, cmd)?,
"clippingMask" => look.clipping_mask = v.as_bool().ok_or_else(|| bad(cmd, "clippingMask must be true or false"))?,
_ => {}
}
}
let raster = RasterExportOptions { ppi, background, anti_alias, ..Default::default() };
let pad = look.add_around;
let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad);
let scale = ppi / 72.0;
// Snap to whole pixels.
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
let nodes: Vec<Node> = roots
.iter()
.filter_map(|id| st.doc.node(*id).cloned())
let nodes: Vec<Node> = nodes
.into_iter()
.map(|mut n| {
n.visible = true;
n
})
.collect();
// Create Clipping Mask: the image is clipped to the art's outline.
// What the art draws, its geometry effects applied.
let b = nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, vectorcraft_render::drawn_bounds(doc, n)));
let b = b.ok_or_else(|| bad(cmd, "selection has no bounds"))?.inflate(pad, pad);
let scale = ppi / 72.0;
// Snap to whole pixels.
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
// Create Clipping Mask: the image is clipped to the art's outline with its geometry effects
// applied, as on the canvas.
let clip = look
.clipping_mask
.then(|| vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect())))
.then(|| {
let outlines = nodes.iter().filter_map(|n| {
let (bp, rule) = vectorcraft_render::drawn_outline(doc, n)?;
let mut p = Node::path(n.id, PathData::from_bezpath(&bp), Appearance::default());
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
*r = rule;
}
Some(Arc::new(p))
});
vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), outlines.collect()))
})
.flatten()
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
let tmp = isolated_doc(&st.doc, nodes);
let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
let tmp = isolated_doc(doc, nodes);
let (png, width, height, warning) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
Ok(Rasterized { png, width, height, xf, clip, warning })
}
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.rasterize";
let roots = selected_roots(s)?;
let st = s.doc()?;
let nodes: Vec<Node> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect();
let image = rasterize_art(&st.doc, nodes, p, C)?;
let (w, h, warning) = (image.width, image.height, image.warning);
if roots.is_empty() {
return Err(bad(C, "nothing selected"));
}
let id = s.edit("Rasterize", |d, sel| {
let key = unique_key(d, "raster");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
let id = d.alloc_id();
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let mut node =
Node::new(id, NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })));
if let Some((path, rule)) = clip {
node = super::rasterfx::clip_group(d, path, rule, node);
}
let node = image.into_node(d);
let id = d.wrap(&roots, |_| node)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "id": id.0, "width": w, "height": h }))
let mut out = json!({ "id": id.0, "width": w, "height": h });
if let Some(w) = warning {
out["warning"] = json!(w);
}
Ok(out)
}
fn has_image(s: &Session) -> std::result::Result<(), String> {
@@ -524,8 +594,8 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
let blob = st.doc.images.get(&im.key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| bad(C, "the image has no embedded pixels"))?;
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32;
let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32;
let gray = bool_or(p, "gray", false);
let colors = vectorcraft_trace::mosaic(&raster, cols, rows);
// Tiles in image space, placed by the image transform (scaled to a new size if asked).
@@ -634,7 +704,8 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
bare.visible = true;
let tmp = isolated_doc(&st.doc, vec![bare]);
let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale);
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
let (png, w, h, warning) =
render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
s.edit("Crop Image", |d, _| {
let key = unique_key(d, "crop");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
@@ -650,7 +721,11 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({ "id": id.0, "width": w, "height": h }))
let mut out = json!({ "id": id.0, "width": w, "height": h });
if let Some(w) = warning {
out["warning"] = json!(w);
}
Ok(out)
}
/// Crop Image's trim: the image cut to the box of its pixels that aren't fully transparent, the
@@ -872,9 +947,11 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result<Value> {
fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let n = s.edit("Convert to Artboards", |d, sel| {
// Measure each object's geometric bounds first, with symbol instances measured by their art.
let symbols = d.symbol_boxes();
let boxes: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, d.node(*id)?.geometric_bounds_with(&symbols)?))).collect();
let mut converted = vec![];
for id in &roots {
let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue };
for (id, b) in boxes {
if b.width() <= 0.0 || b.height() <= 0.0 {
continue;
}
@@ -886,7 +963,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
show_center_mark: false,
show_cross_hairs: false,
});
converted.push(*id);
converted.push(id);
}
if converted.is_empty() {
return Err(EngineError::Other("Convert to Artboards: selection has no area".into()));
@@ -916,8 +993,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
};
let count = s.doc()?.doc.artboards.len();
let n_ab = count.max(1);
// Columns of a grid by row, rows of a grid by column (NaN casts to 0).
let lines = (f64_or(p, "columns", 2.0).clamp(1.0, n_ab as f64) as usize).max(1);
// Columns of a grid by row, rows of a grid by column.
let lines = whole_or(p, "columns", 2, CMD)?.clamp(1, n_ab as u64) as usize;
let (rows, cols) = match layout {
L::GridByRow => (n_ab.div_ceil(lines), lines),
L::GridByColumn => (lines, n_ab.div_ceil(lines)),
@@ -938,8 +1015,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) {
return Ok(json!({ "artboards": count, "rows": rows, "columns": cols }));
}
// With moveArtwork, each top-level object moves with the artboard its center is on; locked and
// With moveArtwork, each top-level object moves with the artboard its center is on, with symbol
// instances measured by their art as `Document::art_on_artboard` measures them; locked and
// hidden art moves only when prefs moveLockedWithArtboard is on.
let symbols = d.symbol_boxes();
let art: Vec<(NodeId, Vec2)> = if move_art {
d.layers
.iter()
@@ -947,8 +1026,8 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.rides_with_artboard(locked_and_hidden))
.filter_map(|n| {
let c = n.geometric_bounds()?.center();
let dl = *rects.iter().position(|r| r.contains(c)).and_then(|i| deltas.get(i))?;
let c = n.geometric_bounds_with(&symbols)?.center();
let dl = *d.artboard_holding(c, false).and_then(|i| deltas.get(i))?;
(dl != Vec2::ZERO).then_some((n.id, dl))
})
.collect()
+35 -3
View File
@@ -114,19 +114,29 @@ fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D:
pub struct DocSettings {
/// The preset's name.
pub name: String,
/// Artboard size in points.
/// Artboard size in points, more than 0 and up to `MAX_SIDE` (a saved or Recent preset's
/// value outside that range is read as the nearer end, and one of 0 or less is read as 1).
#[serde(deserialize_with = "de_side")]
pub width: f64,
#[serde(deserialize_with = "de_side")]
pub height: f64,
pub units: Unit,
/// 1 to `MAX_ARTBOARDS` (a saved or Recent preset's value outside that range is read as the
/// nearer end).
#[serde(deserialize_with = "de_artboards")]
pub artboards: u32,
pub layout: ArtboardLayout,
/// Columns of a grid layout.
pub columns: u32,
/// Space between artboards, points.
/// Space between artboards, points: 0 to `MAX_SPACING` (a saved or Recent preset's value
/// outside that range is read as the nearer end).
#[serde(deserialize_with = "de_spacing")]
pub spacing: f64,
/// Change to Right-to-Left Layout: the first artboard at the right.
pub right_to_left: bool,
/// Top, bottom, left, right, points.
/// Top, bottom, left, right, points: 0 to `MAX_BLEED` each (a saved or Recent preset's value
/// outside that range is read as the nearer end).
#[serde(deserialize_with = "de_bleed")]
pub bleed: [f64; 4],
pub background: Background,
pub color_mode: ColorMode,
@@ -348,6 +358,28 @@ pub fn color_mode_id(m: ColorMode) -> &'static str {
}
}
/// A saved or Recent preset's artboard side: up to [`MAX_SIDE`] points, and 1 when it is 0 or less.
fn de_side<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
let v = f64::deserialize(d)?;
Ok(if v > 0.0 { v.min(MAX_SIDE) } else { 1.0 })
}
/// A saved or Recent preset's artboard count, rounded and kept to 1 to [`MAX_ARTBOARDS`] as
/// `file.new` reads its `artboards` param.
fn de_artboards<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<u32, D::Error> {
Ok(f64::deserialize(d)?.round().clamp(1.0, MAX_ARTBOARDS as f64) as u32)
}
/// A saved or Recent preset's artboard spacing, kept to 0 to [`MAX_SPACING`] points.
fn de_spacing<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
Ok(f64::deserialize(d)?.clamp(0.0, MAX_SPACING))
}
/// A saved or Recent preset's bleed, each side kept to 0 to `MAX_BLEED` points.
fn de_bleed<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<[f64; 4], D::Error> {
Ok(vectorcraft_doc::setup::bleed_in_range(<[f64; 4]>::deserialize(d)?))
}
/// Top-left corners for artboards of `sizes` from `origin`: `columns` per row, `spacing` apart,
/// each column as wide and each row as tall as its largest artboard; `by_column` fills columns
/// top to bottom first, `rtl` puts the first column at the right.
+5
View File
@@ -1061,11 +1061,16 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
}
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
let guides_locked = s.menu.guides_locked;
let st = s.doc_mut()?;
if let Some(i) = st.isolation.take() {
super::distortcmds::finish_edit_text(st, i);
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i);
st.selection.set([target]);
// A guide path isolated while Lock Guides is on isn't selected (#1534).
if guides_locked {
crate::deselect_guides(&st.doc, &mut st.selection);
}
}
st.revision += 1;
ok()
+22 -5
View File
@@ -298,14 +298,31 @@ fn new_color_model(s: &Session, p: &Value) -> Option<vectorcraft_color::cms::Mod
(mode != vectorcraft_doc::ColorMode::Rgb && !bool_or(p, "keepModel", false)).then(|| mode.model())
}
/// `c` in `model` ([`new_color_model`]) when it is an RGB color, converted through the color
/// settings (the working spaces and their intent) as Document Color Mode converts it. Other colors
/// are returned as given.
fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
match (c, model) {
(Color::Rgb { .. }, Some(m)) => {
let cms = vectorcraft_color::cms::active();
cms.convert(&c, m, cms.settings().intent)
}
_ => c,
}
}
/// A color given in `p` as the document stores it: an RGB color in a CMYK document (without
/// `keepModel`) becomes CMYK through the color settings ([`stored_color`]).
pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
stored_color(new_color_model(s, p), c)
}
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]).
/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]),
/// converted through the color settings.
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
let model = new_color_model(s, p);
let new_color = |c: Color| match (c, model) {
(Color::Rgb { .. }, Some(m)) => c.in_model(m),
_ => c,
};
let new_color = |c: Color| stored_color(model, c);
if bool_or(p, "none", false) {
return Ok(Some(Paint::None));
}
+10 -1
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -276,7 +276,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"charAlign",
"proportionalMetrics",
"digits",
"fontAxes",
"burasagari",
"romanHangingPunctuation",
"composer",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
@@ -293,6 +295,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
let char_align = super::textedit::char_align_param(p, C)?;
let digits = super::textedit::digits_param(p, C)?;
let font_axes = super::textedit::font_axes_param(p, C)?;
let mojikumi = match p.get("mojikumi") {
None => None,
Some(v) => Some(match v.as_str() {
@@ -378,6 +381,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = flag("proportionalMetrics") {
st.proportional_metrics = v;
}
if let Some(v) = &font_axes {
super::textedit::change_font_axes(st, v);
}
};
s.edit("Character", |d, _| {
for id in &ids {
@@ -439,6 +445,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = burasagari {
para.burasagari = v;
}
if let Some(v) = flag("romanHangingPunctuation") {
para.roman_hanging = v;
}
if let Some(v) = composer {
para.composer = v;
}
+7 -7
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Pattern"],
None,
"{ids?, name?, tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
"{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
has_selection,
pattern_make
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save a Copy",
[],
None,
"{name?} while editing, save the current tile as a new pattern swatch → {name}",
"{name? (trimmed; blank: <pattern> copy)} while editing, save the current tile as a new pattern swatch → {name}",
in_pattern_edit,
pattern_save_copy
),
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pattern Options",
["Window", "Pattern Options"],
None,
"{name? (default: the pattern being edited), newName?, tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
"{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
has_doc,
pattern_options
),
@@ -294,7 +294,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "width/height must be > 0"));
}
let edit = bool_or(p, "edit", true);
let wanted = str_param(p, "name").map(str::to_string);
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let name = s.edit("Make Pattern", |d, sel| {
let name = match &wanted {
Some(n) => unique_pattern_name(d, n),
@@ -388,12 +388,12 @@ fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
}
fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
let wanted = str_param(p, "name").map(str::to_string);
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let name = s.edit("Save a Copy", |d, _| {
let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?;
let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?;
let art: Vec<Arc<Node>> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default();
let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern)));
let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern.trim())));
def.name = name.clone();
def.art = copies(d, &art).into_iter().map(Arc::new).collect();
if def.size_tile_to_art {
@@ -419,7 +419,7 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("{k} must be > 0")));
}
}
let new_name = str_param(p, "newName").map(str::to_string).filter(|n| !n.is_empty() && *n != name);
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
if let Some(n) = &new_name
&& s.doc()?.doc.pattern(n).is_some()
{
+977
View File
@@ -0,0 +1,977 @@
//! Placing a Word document (`.docx`): its text set as area type with the character and paragraph
//! formatting VectorCraft has, read as ECMA-376 Part 1 describes WordprocessingML
//! (<https://ecma-international.org/publications-and-standards/standards/ecma-376/>), in its
//! Transitional and Strict namespaces.
//!
//! - The package is a ZIP archive ([`fileio::zip`]). `_rels/.rels` names the main document part
//! (else `word/document.xml`), and that part's relationships name its styles and theme parts
//! (else `styles.xml` and `theme/theme1.xml` next to it).
//! - Text: each paragraph (`w:p`) is a paragraph, and so is each table row, whose cells are
//! separated by tabs. A text-wrapping break (`w:br`) and a carriage return (`w:cr`) start a new
//! paragraph; page and column breaks are skipped. `w:tab` and `w:ptab` are tabs, and
//! `w:noBreakHyphen` and `w:softHyphen` are their hyphens. Text in insertions, moves, hyperlinks,
//! smart tags, content controls, custom XML and simple fields is read, and of a complex field,
//! its result.
//! - Character formatting: the font (`w:rFonts`: `w:asciiTheme`, `w:ascii`, `w:hAnsiTheme`, then
//! `w:hAnsi`; a theme font is the theme's major or minor Latin font), the size, bold, italic,
//! underline (every style but `none`), strikethrough and double strikethrough, all caps, small
//! caps, superscript and subscript (`w:vertAlign`), raised and lowered text (`w:position`) and
//! the color.
//! - Paragraph formatting: alignment (`w:jc`), start and end indents (`w:start` or `w:left`,
//! `w:end` or `w:right`: the right and left indents of a right-to-left paragraph), first-line and
//! hanging indents, space before and after, and direction (`w:bidi`).
//! - Styles (§17.7.2): the document defaults, then the paragraph style (else the default paragraph
//! style) and the styles it is based on, then the run's character style (else the default
//! character style) and the styles it is based on, then direct formatting. Bold, italic, caps,
//! small caps, strikethrough and hidden text are toggle properties (§17.7.3): a style that turns
//! one on reverses the state the levels before it give, and direct formatting sets it.
//!
//! Hidden text (`w:vanish`), deleted text, field instructions, drawings, pictures, objects, symbol
//! characters (`w:sym`), note and comment references, headers, footers, notes, comments and list
//! numbers are left out. A package with more than [`MAX_FILES`] files, a part that unpacks to more
//! than [`MAX_PART`] bytes or nests elements more than [`MAX_NESTING`] deep, and a document with
//! more than [`MAX_PARAGRAPHS`] paragraphs, [`MAX_RUNS`] runs of one formatting or
//! [`text::MAX_TEXT`] bytes of text are refused. Tables, content controls and other containers
//! nested more than [`MAX_DEPTH`] deep are skipped, and so are fields nested deeper.
use std::collections::HashMap;
use roxmltree::Node;
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
use super::text::{self, Story};
use super::*;
use crate::cmd::fileio::zip::{self, Archive, ReadError};
/// Word documents as `file.place` reports them (not a format `document.open` reads).
pub(super) const FORMAT: fileio::Format = fileio::Format {
id: "docx",
label: "Word",
extensions: fileio::DOCX_EXTS,
mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
read: true,
write: false,
raster: false,
options: &[],
};
/// The most files a Word document's package may hold.
const MAX_FILES: usize = 10_000;
/// The most bytes a part read from the package may unpack to.
const MAX_PART: usize = 16 << 20;
/// The most XML nodes a part read from the package may have.
const MAX_NODES: u32 = 2_000_000;
/// The deepest nesting of XML elements a part read from the package may have.
const MAX_NESTING: usize = 256;
/// The deepest nesting of tables, content controls and other containers read; deeper ones are
/// skipped, and so are fields nested deeper.
const MAX_DEPTH: usize = 64;
/// The most styles read from the styles part.
const MAX_STYLES: usize = 4096;
/// The most styles one style's `w:basedOn` chain takes in.
const MAX_BASED_ON: usize = 32;
/// The most fonts the document's formatting names.
const MAX_FONTS: usize = 4096;
/// The most bytes of a font name kept.
const MAX_NAME: usize = 256;
/// The most paragraphs placed type takes from one Word document.
const MAX_PARAGRAPHS: usize = 100_000;
/// The most runs of one character formatting placed type takes from one Word document.
const MAX_RUNS: usize = 100_000;
/// WordprocessingML's namespaces: Transitional, then Strict.
const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"];
/// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds
/// `word/document.xml`?
pub(super) fn is_docx(name: &str, bytes: &[u8]) -> bool {
zip::is_zip(bytes)
&& (fileio::DOCX_EXTS.contains(&fileio::extension(name).as_str())
|| Archive::open(bytes, MAX_FILES).is_ok_and(|z| z.contains("word/document.xml")))
}
/// The text of a Word document's `bytes` (a ZIP package) with its character and paragraph
/// formatting.
pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> {
let zip = Archive::open(bytes, MAX_FILES).map_err(|e| package_error(cmd, e))?;
let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into());
let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) };
let doc = parse(&xml, &main, cmd)?;
if local(doc.root_element()) != Some("document") {
return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document"));
}
let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default();
let mut r = Reader::default();
let theme = relationship(&zip, &main, "theme", cmd)?.unwrap_or_else(|| format!("{folder}theme/theme1.xml"));
if let Some(xml) = part(&zip, &theme, cmd)? {
r.theme_fonts(&parse(&xml, &theme, cmd)?);
}
let styles = relationship(&zip, &main, "styles", cmd)?.unwrap_or_else(|| format!("{folder}styles.xml"));
if let Some(xml) = part(&zip, &styles, cmd)? {
r.styles(&parse(&xml, &styles, cmd)?);
}
if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) {
r.block(body, 0);
}
r.finish(cmd)
}
/// The error for a package (or a part of it) the ZIP reader can't read.
fn package_error(cmd: &str, e: ReadError) -> EngineError {
bad(
cmd,
match e {
ReadError::NotZip => "not a Word document: it isn't a ZIP package".to_string(),
ReadError::TooManyFiles => format!("the Word document's package has more than {MAX_FILES} files"),
ReadError::Zip64 => "the Word document's package uses ZIP64 records, which VectorCraft doesn't read".to_string(),
ReadError::Encrypted(name) => format!("`{name}` in the Word document is encrypted"),
ReadError::Method(name, m) => format!("`{name}` in the Word document uses compression method {m}, which VectorCraft doesn't read"),
ReadError::TooLarge(name) => format!("`{name}` in the Word document is too large to place (at most {} MB unpacked)", MAX_PART >> 20),
ReadError::Damaged(name) if name.is_empty() => "the Word document's package is damaged".to_string(),
ReadError::Damaged(name) => format!("the Word document's package is damaged: `{name}` can't be read"),
},
)
}
/// The part `name` of the package as text (UTF-8, or UTF-16 with a byte-order mark); None: the
/// package has no such part.
fn part(zip: &Archive, name: &str, cmd: &str) -> Result<Option<String>> {
let Some(bytes) = zip.read(name, MAX_PART).map_err(|e| package_error(cmd, e))? else { return Ok(None) };
let utf16 = |rest: &[u8], le: bool| -> String {
let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
};
let text = match &bytes[..] {
[0xEF, 0xBB, 0xBF, rest @ ..] => std::str::from_utf8(rest).map(str::to_string),
[0xFF, 0xFE, rest @ ..] => Ok(utf16(rest, true)),
[0xFE, 0xFF, rest @ ..] => Ok(utf16(rest, false)),
_ => std::str::from_utf8(&bytes).map(str::to_string),
};
text.map(Some).map_err(|_| bad(cmd, format!("`{name}` in the Word document is not UTF-8 or UTF-16 text")))
}
/// The XML of the part `name`.
fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<'a>> {
// roxmltree parses nested elements recursively.
if nesting(xml) > MAX_NESTING {
return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")));
}
let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() };
roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")))
}
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
/// CDATA sections, processing instructions, declarations and quoted attribute values.
fn nesting(xml: &str) -> usize {
let b = xml.as_bytes();
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
// The index just past the first `end` at or after `from` (the end of the text if none).
let past = |from: usize, end: &[u8]| {
b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len())
};
while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) {
let start = i + at;
let rest = b.get(start..).unwrap_or_default();
if rest.starts_with(b"<!--") {
i = past(start + 4, b"-->");
} else if rest.starts_with(b"<![CDATA[") {
i = past(start + 9, b"]]>");
} else if rest.starts_with(b"<?") {
i = past(start + 2, b"?>");
} else if rest.starts_with(b"<!") || rest.starts_with(b"</") {
depth = depth.saturating_sub(usize::from(rest.starts_with(b"</")));
i = past(start + 2, b">");
} else {
// A start tag: up to its `>` outside quotes; `/>` closes it at once.
let mut j = start + 1;
let mut quote = None;
while let Some(&c) = b.get(j) {
match (quote, c) {
(None, b'"' | b'\'') => quote = Some(c),
(Some(q), _) if c == q => quote = None,
(None, b'>') => break,
_ => {}
}
j += 1;
}
if b.get(j.saturating_sub(1)) != Some(&b'/') {
depth += 1;
most = most.max(depth);
}
i = j + 1;
}
}
most
}
/// The part that the first relationship of part `source` (`""`: the package) whose type ends in
/// `/{kind}` points to; None when the package has no relationships part for `source` or it names
/// no such part.
fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result<Option<String>> {
let (folder, file) = source.rfind('/').map_or(("", source), |i| (source.get(..=i).unwrap_or_default(), source.get(i + 1..).unwrap_or_default()));
let rels = format!("{folder}_rels/{file}.rels");
let Some(xml) = part(zip, &rels, cmd)? else { return Ok(None) };
let doc = parse(&xml, &rels, cmd)?;
let target = doc
.root_element()
.children()
.filter(|n| n.tag_name().name() == "Relationship" && n.attribute("TargetMode") != Some("External"))
.find(|n| n.attribute("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind)))
.and_then(|n| n.attribute("Target"));
Ok(target.map(|t| resolve(folder, t)))
}
/// The part name `target` gives, relative to the folder `folder` (`""` or ending in `/`).
fn resolve(folder: &str, target: &str) -> String {
let joined = match target.strip_prefix('/') {
Some(absolute) => absolute.to_string(),
None => format!("{folder}{target}"),
};
let mut parts: Vec<&str> = vec![];
for segment in joined.split('/') {
match segment {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
/// `n`'s local name when it is a WordprocessingML element.
fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> {
(n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name())
}
/// `n`'s first WordprocessingML child element named `name`.
fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option<Node<'a, 'i>> {
n.children().find(|c| local(*c) == Some(name))
}
/// `n`'s WordprocessingML attribute `name`.
fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> {
n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value())
}
/// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`.
fn on(n: Node) -> bool {
!matches!(attr(n, "val"), Some("false" | "0" | "off"))
}
/// A measure `v` in units of `1 / per_pt` point: a number in those units, or a number with a unit
/// (`mm`, `cm`, `in`, `pt`, `pc`, `pi`).
fn measure(v: &str, per_pt: f64) -> Option<f64> {
const UNITS: [(&str, f64); 6] = [("mm", 72.0 / 25.4), ("cm", 72.0 / 2.54), ("in", 72.0), ("pt", 1.0), ("pc", 12.0), ("pi", 12.0)];
let v = v.trim();
let n = match UNITS.iter().find_map(|(unit, pt)| v.strip_suffix(unit).map(|n| (n, pt))) {
Some((n, pt)) => n.trim().parse::<f64>().ok()? * pt * per_pt,
None => v.parse::<f64>().ok()?,
};
n.is_finite().then_some(n)
}
/// Twentieths of a point.
fn twips(n: Node, name: &str) -> Option<f64> {
attr(n, name).and_then(|v| measure(v, 20.0))
}
/// Half points.
fn half_points(n: Node) -> Option<f64> {
attr(n, "val").and_then(|v| measure(v, 2.0))
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Script {
#[default]
Normal,
Super,
Sub,
}
/// The run properties one level of the style hierarchy sets (None: it doesn't set that one).
#[derive(Clone, Copy, Debug, Default)]
struct RunProps {
/// The font's number in [`Reader::fonts`].
font: Option<usize>,
half_points: Option<f64>,
bold: Option<bool>,
italic: Option<bool>,
caps: Option<bool>,
small_caps: Option<bool>,
strike: Option<bool>,
dstrike: Option<bool>,
hidden: Option<bool>,
underline: Option<bool>,
script: Option<Script>,
/// Raised (positive) or lowered text in half points.
shift: Option<f64>,
/// Some(None): automatic (the default color).
color: Option<Option<[u8; 3]>>,
}
/// Character formatting.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Chars {
font: Option<usize>,
/// None: VectorCraft's default size.
half_points: Option<f64>,
bold: bool,
italic: bool,
caps: bool,
small_caps: bool,
strike: bool,
dstrike: bool,
hidden: bool,
underline: bool,
script: Script,
shift: f64,
color: Option<[u8; 3]>,
}
impl Chars {
/// `p` applied over this formatting: as a style's (a toggle property turned on reverses its
/// state, turned off leaves it) or as direct formatting (it sets each property).
fn apply(&mut self, p: &RunProps, style: bool) {
let toggle = |state: &mut bool, v: Option<bool>| match v {
Some(true) if style => *state = !*state,
Some(v) if !style => *state = v,
_ => {}
};
toggle(&mut self.bold, p.bold);
toggle(&mut self.italic, p.italic);
toggle(&mut self.caps, p.caps);
toggle(&mut self.small_caps, p.small_caps);
toggle(&mut self.strike, p.strike);
toggle(&mut self.hidden, p.hidden);
self.font = p.font.or(self.font);
self.half_points = p.half_points.or(self.half_points);
self.dstrike = p.dstrike.unwrap_or(self.dstrike);
self.underline = p.underline.unwrap_or(self.underline);
self.script = p.script.unwrap_or(self.script);
self.shift = p.shift.unwrap_or(self.shift);
self.color = p.color.unwrap_or(self.color);
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Align {
#[default]
Left,
Center,
Right,
/// Justified, the last line at the start (the right of a right-to-left paragraph).
Both,
/// Justified, the last line too.
Distribute,
Start,
End,
}
/// The paragraph properties one level of the style hierarchy sets; lengths in twips.
#[derive(Clone, Copy, Debug, Default)]
struct ParaProps {
align: Option<Align>,
/// The indent at the paragraph's start (the left of a left-to-right paragraph).
start: Option<f64>,
/// The indent at the paragraph's end.
end: Option<f64>,
first: Option<f64>,
before: Option<f64>,
after: Option<f64>,
bidi: Option<bool>,
}
/// Paragraph formatting; lengths in twips.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Para {
align: Align,
start: f64,
end: f64,
first: f64,
before: f64,
after: f64,
rtl: Option<bool>,
}
impl Para {
fn apply(&mut self, p: &ParaProps) {
self.align = p.align.unwrap_or(self.align);
self.start = p.start.unwrap_or(self.start);
self.end = p.end.unwrap_or(self.end);
self.first = p.first.unwrap_or(self.first);
self.before = p.before.unwrap_or(self.before);
self.after = p.after.unwrap_or(self.after);
self.rtl = p.bidi.or(self.rtl);
}
}
/// A style of the styles part.
struct Style {
based_on: Option<String>,
run: RunProps,
para: ParaProps,
}
#[derive(Default)]
struct Reader {
/// The theme's major and minor Latin fonts.
theme: [Option<String>; 2],
fonts: Vec<String>,
font_numbers: HashMap<String, usize>,
styles: HashMap<String, Style>,
default_run: RunProps,
default_para: ParaProps,
/// The default paragraph style's id.
default_style: Option<String>,
/// The default character style's id.
default_char_style: Option<String>,
/// The paragraph being read: its formatting, and its run formatting before character styles
/// and direct formatting.
para: Para,
base: Chars,
/// A paragraph that has ended (its formatting and its mark's): its break goes before the
/// next text.
pending: Option<(Para, Chars)>,
/// The last cell's paragraph, which its row's break ends.
cell_end: Option<(Para, Chars)>,
/// A cell just ended: a tab goes before the next character of its row.
cell_tab: bool,
/// The complex fields open: true while their instructions are being read.
fields: Vec<bool>,
/// Fields begun past [`MAX_DEPTH`] and not yet ended.
field_overflow: usize,
/// The text, in runs of one character formatting; paragraphs end with `\n`.
spans: Vec<(String, Chars)>,
/// The bytes of text in `spans`.
len: usize,
/// The formatting of each paragraph that has ended.
paras: Vec<Para>,
/// Past [`MAX_PARAGRAPHS`], [`MAX_RUNS`] or [`text::MAX_TEXT`].
full: bool,
}
impl Reader {
/// The major and minor fonts of a theme part's font scheme.
fn theme_fonts(&mut self, doc: &roxmltree::Document) {
for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) {
*slot = doc
.descendants()
.find(|n| n.tag_name().name() == scheme)
.and_then(|n| n.children().find(|c| c.tag_name().name() == "latin"))
.and_then(|n| n.attribute("typeface"))
.map(font_name)
.filter(|f| !f.is_empty());
}
}
/// The document defaults and the styles of the styles part.
fn styles(&mut self, doc: &roxmltree::Document) {
for n in doc.root_element().children() {
match local(n) {
Some("docDefaults") => {
let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner));
if let Some(rpr) = props("rPrDefault", "rPr") {
self.default_run = self.run_props(rpr);
}
if let Some(ppr) = props("pPrDefault", "pPr") {
self.default_para = para_props(ppr);
}
}
Some("style") if self.styles.len() < MAX_STYLES => {
let Some(id) = attr(n, "styleId") else { continue };
let default = match attr(n, "type") {
Some("paragraph") => Some(&mut self.default_style),
Some("character") => Some(&mut self.default_char_style),
_ => None,
};
if let Some(default) = default.filter(|_| attr(n, "default").is_some_and(|v| matches!(v, "1" | "true" | "on"))) {
default.get_or_insert_with(|| id.to_string());
}
let run = child(n, "rPr").map(|r| self.run_props(r)).unwrap_or_default();
let para = child(n, "pPr").map(para_props).unwrap_or_default();
let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).map(str::to_string);
self.styles.entry(id.to_string()).or_insert(Style { based_on, run, para });
}
_ => {}
}
}
}
/// Style `id` and the styles it is based on, the first of the chain first.
fn chain(&self, id: Option<&str>) -> Vec<&Style> {
let mut ids: Vec<&str> = vec![];
let mut chain = vec![];
let mut next = id;
while let Some(id) = next {
if ids.contains(&id) || ids.len() >= MAX_BASED_ON {
break;
}
let Some(style) = self.styles.get(id) else { break };
ids.push(id);
chain.push(style);
next = style.based_on.as_deref();
}
chain.reverse();
chain
}
/// The number of the font a `w:rFonts` element names.
fn font(&mut self, n: Node) -> Option<usize> {
let theme = |v: &str| self.theme.get(usize::from(v.starts_with("minor"))).and_then(Option::as_deref);
let name = attr(n, "asciiTheme")
.and_then(theme)
.or_else(|| attr(n, "ascii"))
.or_else(|| attr(n, "hAnsiTheme").and_then(theme))
.or_else(|| attr(n, "hAnsi"))
.map(font_name)?;
if name.is_empty() {
return None;
}
if let Some(&i) = self.font_numbers.get(&name) {
return Some(i);
}
if self.fonts.len() >= MAX_FONTS {
return None;
}
self.fonts.push(name.clone());
self.font_numbers.insert(name, self.fonts.len() - 1);
Some(self.fonts.len() - 1)
}
/// The run properties of a `w:rPr` element.
fn run_props(&mut self, rpr: Node) -> RunProps {
let mut p = RunProps::default();
for n in rpr.children() {
match local(n) {
Some("rFonts") => p.font = self.font(n),
Some("b") => p.bold = Some(on(n)),
Some("i") => p.italic = Some(on(n)),
Some("caps") => p.caps = Some(on(n)),
Some("smallCaps") => p.small_caps = Some(on(n)),
Some("strike") => p.strike = Some(on(n)),
Some("dstrike") => p.dstrike = Some(on(n)),
Some("vanish") => p.hidden = Some(on(n)),
Some("u") => p.underline = Some(attr(n, "val") != Some("none")),
Some("vertAlign") => {
p.script = match attr(n, "val") {
Some("superscript") => Some(Script::Super),
Some("subscript") => Some(Script::Sub),
Some("baseline") => Some(Script::Normal),
_ => p.script,
}
}
Some("position") => p.shift = half_points(n).or(p.shift),
Some("sz") => p.half_points = half_points(n).filter(|v| *v > 0.0).or(p.half_points),
Some("color") => {
let hex = |v: &str| -> Option<[u8; 3]> {
let v = v.trim();
if v.len() != 6 || !v.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let byte = |i: usize| v.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
Some([byte(0)?, byte(2)?, byte(4)?])
};
p.color = match attr(n, "val") {
Some("auto") => Some(None),
Some(v) => hex(v).map(Some).or(p.color),
None => p.color,
}
}
_ => {}
}
}
p
}
/// The paragraphs, tables and containers of them that are `n`'s children.
fn block(&mut self, n: Node, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in n.children() {
if self.full {
return;
}
match local(c) {
Some("p") => self.paragraph(c, depth),
Some("tbl") => self.table(c, depth),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
self.block(content, depth + 1);
}
}
Some("customXml" | "ins" | "moveTo") => self.block(c, depth + 1),
_ => {}
}
}
}
/// A table: each row a paragraph, its cells separated by tabs.
fn table(&mut self, tbl: Node, depth: usize) {
for row in items(tbl, "tr", depth) {
for cell in items(row, "tc", depth) {
self.flush();
self.block(cell, depth + 1);
// The cell's last paragraph goes on in the next cell, after a tab.
let end = self.pending.take();
if self.cell_tab
&& let Some((_, mark)) = end
{
self.push('\t', mark);
}
self.cell_end = end.or(self.cell_end);
self.cell_tab = true;
}
self.cell_tab = false;
self.pending = self.cell_end.take();
}
}
/// A paragraph.
fn paragraph(&mut self, p: Node, depth: usize) {
self.flush();
let ppr = child(p, "pPr");
let style = ppr.and_then(|n| child(n, "pStyle")).and_then(|n| attr(n, "val")).map(str::to_string).or_else(|| self.default_style.clone());
let mut para = Para::default();
para.apply(&self.default_para);
let mut base = Chars::default();
base.apply(&self.default_run, true);
for s in self.chain(style.as_deref()) {
para.apply(&s.para);
base.apply(&s.run, true);
}
if let Some(n) = ppr {
para.apply(&para_props(n));
}
(self.para, self.base) = (para, base);
self.inline(p, depth + 1);
// The paragraph mark's run properties are direct formatting over the paragraph style's.
let mut mark = base;
if let Some(rpr) = ppr.and_then(|n| child(n, "rPr")) {
let props = self.run_props(rpr);
mark.apply(&props, false);
}
// A line break at the paragraph's end is written before the paragraph ends, and a hidden
// paragraph mark joins the paragraph to the next.
if !mark.hidden {
self.flush();
self.pending = Some((para, mark));
}
}
/// The runs of a paragraph, and the containers of runs in it.
fn inline(&mut self, n: Node, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in n.children() {
match local(c) {
Some("r") => self.run(c),
Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
self.inline(content, depth + 1);
}
}
_ => {}
}
}
}
/// A run.
fn run(&mut self, r: Node) {
let rpr = child(r, "rPr");
let mut chars = self.base;
let style = rpr.and_then(|n| child(n, "rStyle")).and_then(|n| attr(n, "val")).or(self.default_char_style.as_deref());
for s in self.chain(style) {
chars.apply(&s.run, true);
}
if let Some(n) = rpr {
let props = self.run_props(n);
chars.apply(&props, false);
}
for c in r.children() {
match local(c) {
Some("t") => {
// Comments and processing instructions may split the text.
for ch in c.children().filter(|n| n.is_text()).filter_map(|n| n.text()).flat_map(str::chars) {
self.put(ch, chars);
}
}
Some("tab" | "ptab") => self.put('\t', chars),
Some("noBreakHyphen") => self.put('\u{2011}', chars),
Some("softHyphen") => self.put('\u{AD}', chars),
Some("br") if matches!(attr(c, "type"), None | Some("textWrapping")) => self.line_break(chars),
Some("cr") => self.line_break(chars),
Some("fldChar") => match attr(c, "fldCharType") {
Some("begin") if self.fields.len() < MAX_DEPTH => self.fields.push(true),
Some("begin") => self.field_overflow += 1,
Some("separate") if self.field_overflow == 0 => {
if let Some(instructions) = self.fields.last_mut() {
*instructions = false;
}
}
Some("end") if self.field_overflow > 0 => self.field_overflow -= 1,
Some("end") => {
self.fields.pop();
}
_ => {}
},
_ => {}
}
}
}
/// Text is not read: hidden, or a field's instructions.
fn skipped(&self, chars: &Chars) -> bool {
chars.hidden || self.field_overflow > 0 || self.fields.contains(&true)
}
/// Character `c` of the document's text in formatting `chars`.
fn put(&mut self, c: char, chars: Chars) {
if self.skipped(&chars) {
return;
}
self.flush();
if std::mem::take(&mut self.cell_tab) {
self.push('\t', chars);
}
self.push(c, chars);
}
/// A line break: the rest of the paragraph is a new paragraph in the same formatting.
fn line_break(&mut self, chars: Chars) {
if self.skipped(&chars) {
return;
}
self.flush();
self.pending = Some((self.para, chars));
}
/// Writes the break of the paragraph that has ended.
fn flush(&mut self) {
let Some((para, mark)) = self.pending.take() else { return };
if self.paras.len() >= MAX_PARAGRAPHS {
self.full = true;
return;
}
self.paras.push(para);
// A cell that starts with a break is still separated from the cell before it.
if std::mem::take(&mut self.cell_tab) {
self.push('\t', mark);
}
self.push('\n', mark);
}
/// Adds `c` to the text in formatting `chars`.
fn push(&mut self, c: char, chars: Chars) {
self.len += c.len_utf8();
if self.len > text::MAX_TEXT {
self.full = true;
return;
}
let runs = self.spans.len();
match self.spans.last_mut() {
Some((s, f)) if *f == chars => s.push(c),
_ if runs >= MAX_RUNS => self.full = true,
_ => self.spans.push((c.to_string(), chars)),
}
}
/// The character attributes of formatting `c`.
fn style(&self, c: &Chars) -> CharStyle {
let mut s = CharStyle::default();
if let Some(name) = c.font.and_then(|i| self.fonts.get(i)) {
s.font_family.clone_from(name);
}
s.font_style = match (c.bold, c.italic) {
(true, true) => "Bold Italic",
(true, false) => "Bold",
(false, true) => "Italic",
(false, false) => "Regular",
}
.into();
if let Some(v) = c.half_points {
s.size = (v / 2.0).clamp(0.1, 1296.0);
}
s.baseline_shift = (c.shift / 2.0).clamp(-1296.0, 1296.0);
if let Some([r, g, b]) = c.color {
s.fill = Paint::solid(vectorcraft_color::Color::rgb8(r, g, b));
}
s.underline = c.underline;
s.strikethrough = c.strike || c.dstrike;
s.all_caps = c.caps;
// Document Setup's proportions replace these when the type is placed.
s.small_caps = c.small_caps.then_some(70.0);
s.position = match c.script {
Script::Normal => CharPosition::Normal,
Script::Super => CharPosition::Superscript(ScriptMetrics::DEFAULT),
Script::Sub => CharPosition::Subscript(ScriptMetrics::DEFAULT),
};
s
}
/// The text read, without blank paragraphs at its end, and each paragraph's attributes.
fn finish(mut self, cmd: &str) -> Result<Story> {
if self.full {
return Err(bad(
cmd,
format!(
"the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {} MB of text)",
text::MAX_TEXT >> 20
),
));
}
let last = self.pending.take().map_or(self.para, |(para, _)| para);
self.paras.push(last);
let mut all: String = self.spans.iter().map(|(s, _)| s.as_str()).collect();
while self.paras.len() > 1
&& let Some(at) = all.rfind('\n')
&& all.get(at + 1..).is_some_and(|tail| tail.trim().is_empty())
{
all.truncate(at);
self.paras.pop();
}
let mut keep = all.len();
let mut runs: Vec<TextRun> = vec![];
for (s, chars) in &self.spans {
let piece = s.get(..keep.min(s.len())).unwrap_or_default();
keep -= piece.len();
if piece.is_empty() {
continue;
}
let style = self.style(chars);
match runs.last_mut() {
Some(r) if r.style == style => r.text.push_str(piece),
_ => runs.push(TextRun::new(piece, style)),
}
}
let points = |v: f64| (v / 20.0).clamp(-1296.0, 1296.0);
let paras = self
.paras
.iter()
.map(|p| {
let rtl = p.rtl == Some(true);
let (left, right) = if rtl { (p.end, p.start) } else { (p.start, p.end) };
ParaStyle {
justify: match p.align {
Align::Left => Justify::Left,
Align::Center => Justify::Center,
Align::Right => Justify::Right,
Align::Both if rtl => Justify::JustifyRight,
Align::Both => Justify::JustifyLeft,
Align::Distribute => Justify::JustifyAll,
Align::Start => Justify::Auto,
Align::End if rtl => Justify::Left,
Align::End => Justify::Right,
},
left_indent: points(left),
right_indent: points(right),
first_line_indent: points(p.first),
space_before: points(p.before),
space_after: points(p.after),
direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }),
..ParaStyle::default()
}
})
.collect();
Ok(Story { runs, paras })
}
}
/// Font name `name` trimmed, and cut at the last character that ends within [`MAX_NAME`] bytes.
fn font_name(name: &str) -> String {
let name = name.trim();
let end = (0..=MAX_NAME.min(name.len())).rev().find(|&i| name.is_char_boundary(i)).unwrap_or_default();
name.get(..end).unwrap_or_default().to_string()
}
/// The paragraph properties of a `w:pPr` element.
fn para_props(ppr: Node) -> ParaProps {
let mut p = ParaProps::default();
for n in ppr.children() {
match local(n) {
Some("jc") => {
p.align = match attr(n, "val") {
Some("left") => Some(Align::Left),
Some("center") => Some(Align::Center),
Some("right") => Some(Align::Right),
Some("both" | "lowKashida" | "mediumKashida" | "highKashida") => Some(Align::Both),
Some("distribute" | "thaiDistribute") => Some(Align::Distribute),
Some("start") => Some(Align::Start),
Some("end") => Some(Align::End),
_ => p.align,
}
}
Some("ind") => {
// `w:left` and `w:right` (Transitional) are the start and end indents too.
p.start = twips(n, "start").or_else(|| twips(n, "left")).or(p.start);
p.end = twips(n, "end").or_else(|| twips(n, "right")).or(p.end);
// A hanging indent wins over a first-line indent.
p.first = twips(n, "hanging").map(|v| -v).or_else(|| twips(n, "firstLine")).or(p.first);
}
Some("spacing") => {
p.before = twips(n, "before").or(p.before);
p.after = twips(n, "after").or(p.after);
}
Some("bidi") => p.bidi = Some(on(n)),
_ => {}
}
}
p
}
/// `n`'s children named `name`, and those inside content controls and custom XML among them.
fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec<Node<'a, 'i>> {
let mut out = vec![];
if depth > MAX_DEPTH {
return out;
}
for c in n.children() {
match local(c) {
Some(l) if l == name => out.push(c),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
out.extend(items(content, name, depth + 1));
}
}
Some("customXml") => out.extend(items(c, name, depth + 1)),
_ => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_read_numbers_and_units() {
assert_eq!(measure("240", 20.0), Some(240.0));
assert_eq!(measure("12pt", 20.0), Some(240.0));
assert_eq!(measure("1in", 2.0), Some(144.0));
assert_eq!(measure("2.54cm", 1.0).map(|v| (v * 1e9).round() / 1e9), Some(72.0));
assert_eq!(measure("1e999", 20.0), None);
assert_eq!(measure("wide", 20.0), None);
}
#[test]
fn nesting_counts_elements_outside_markup_that_is_not_an_element() {
assert_eq!(nesting(r#"<?xml version="1.0"?><a><b x="1>2" y='</c>'><c/></b><!-- <d><e> --><![CDATA[<f><g>]]></a>"#), 2);
assert_eq!(nesting("<a><b><c>"), 3, "unclosed elements count");
assert_eq!(nesting("</a></b><a>"), 1);
assert_eq!(nesting("<a x=\"unterminated"), 1);
assert_eq!(nesting(""), 0);
}
#[test]
fn targets_resolve_against_their_folder() {
assert_eq!(resolve("", "word/document.xml"), "word/document.xml");
assert_eq!(resolve("word/", "styles.xml"), "word/styles.xml");
assert_eq!(resolve("word/", "../customXml/item1.xml"), "customXml/item1.xml");
assert_eq!(resolve("word/", "/word/theme/./theme1.xml"), "word/theme/theme1.xml");
}
}
+15 -3
View File
@@ -17,6 +17,8 @@
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
//! [`text`]).
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
//! - A Word document (`.docx`) becomes area type with the character and paragraph formatting its
//! styles and direct formatting give ([`docx`]).
//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document:
//! one locked object showing the artboard `page`, linked to the file and read again when it
//! changes ([`document`]); without Link, an editable copy of its art.
@@ -28,6 +30,7 @@
pub(crate) mod adopt;
pub(crate) mod document;
mod docx;
mod rtf;
mod text;
@@ -98,7 +101,8 @@ pub fn specs() -> Vec<CommandSpec> {
/// A file's art at its natural size, before it joins a document.
enum Art {
Image(RasterImage),
/// A text or RTF file's text, placed as area type filling the natural box.
/// A text file's, an RTF file's or a Word document's text, placed as area type filling the
/// natural box.
Text(text::Story),
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
/// bounds (not the artboard) are shown.
@@ -155,6 +159,14 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
}
return Ok(Loaded { name, format: &rtf::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
}
// A Word document is recognized by its name or by its package's `word/document.xml`.
if docx::is_docx(src.name, &src.bytes) {
let story = docx::import(&src.bytes, cmd)?;
if story.plain_text().trim().is_empty() {
return Err(bad(cmd, format!("`{name}` has no text to place")));
}
return Ok(Loaded { name, format: &docx::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
}
let format = fileio::detect(src.name, &src.bytes)
.ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?;
let page = match p.get("page") {
@@ -448,9 +460,9 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
)
}
/// The artboard under `at`, else the first.
/// The artboard under `at` (its edges included, [`Document::artboard_holding`]), else the first.
fn board_at(d: &Document, at: Option<Point>) -> Option<Rect> {
at.and_then(|c| d.artboards.iter().find(|a| a.rect.contains(c))).or(d.artboards.first()).map(|a| a.rect)
at.and_then(|c| d.artboard_holding(c, false)).and_then(|i| d.artboards.get(i)).or(d.artboards.first()).map(|a| a.rect)
}
/// Placed type's frame without `rect` or Replace: the artboard under `at` (else the first) less a
+3 -2
View File
@@ -17,7 +17,8 @@ pub(super) const FORMAT: fileio::Format = fileio::Format {
options: &[],
};
/// The most bytes a text or RTF file placed as type may have.
/// The most bytes a text or RTF file placed as type may have, and the most bytes of text a Word
/// document may give.
pub(super) const MAX_TEXT: usize = 4 << 20;
/// Text Import Options (`file.place`'s `text` param).
@@ -190,7 +191,7 @@ fn tabs_for_spaces(s: &str, n: usize) -> String {
out
}
/// The text placed type gets from a text or RTF file.
/// The text placed type gets from a text file, an RTF file or a Word document.
pub(super) struct Story {
/// The text in runs of one character formatting; paragraphs are separated by `\n`.
pub(super) runs: Vec<vectorcraft_doc::TextRun>,
+18 -10
View File
@@ -1,7 +1,8 @@
//! `plugin.*` commands: sandboxed WebAssembly plug-ins (`vectorcraft-plugins`).
//!
//! Plug-ins are installed per process (`plugin.install`, or every `*.wasm` in the Additional
//! Plug-ins Folder preference) and listed with `plugin.list` / `plugin.info`. Object filters run
//! Plug-ins are installed with `plugin.install` (kept across restarts in the apps' plug-in folder,
//! [`Session::attach_plugin_store`]) or from every `*.wasm` in the Additional Plug-ins Folder
//! 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`.
@@ -85,19 +86,20 @@ const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
/// The `.wasm` extension plug-in files have (File › Open installs them).
pub const EXTS: &[&str] = &["wasm"];
fn install(_: &mut Session, p: &Value) -> Result<Value> {
fn install(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.install";
let plugin = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
let (plugin, bytes) = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
(_, Some(data)) => {
if data.len() > MAX_DATA_CHARS {
return Err(bad(C, "`dataBase64` is larger than the module limit"));
}
let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, "`dataBase64` is not valid base64"))?;
let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?;
match str_param(p, "name") {
let plugin = match str_param(p, "name") {
Some(name) => plugin.with_source(name),
None => plugin,
}
};
(plugin, bytes)
}
(Some(path), None) => load_path(path)?,
(None, None) => return Err(bad(C, "give `path` (a .wasm file) or `dataBase64` (the module)")),
@@ -105,17 +107,22 @@ fn install(_: &mut Session, p: &Value) -> Result<Value> {
if !p.get("replace").and_then(Value::as_bool).unwrap_or(true) && registry::get(plugin.id()).is_some() {
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
}
// Installed plug-ins stay installed after a restart (#2541).
s.store_plugin(plugin.id(), &bytes);
Ok(describe(&registry::install(plugin)))
}
/// The plug-in in the `.wasm` file `path`, and its module.
#[cfg(not(target_arch = "wasm32"))]
fn load_path(path: &str) -> Result<Plugin> {
fn load_path(path: &str) -> Result<(Plugin, Vec<u8>)> {
crate::file_access::check_read(path).map_err(EngineError::Other)?;
registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err)
let limits = vectorcraft_plugins::Limits::default();
let bytes = crate::store::read_capped(std::path::Path::new(path), limits.max_module_bytes as u64).map_err(EngineError::Other)?;
Ok((Plugin::load(&bytes, limits).map_err(err)?.with_source(path), bytes))
}
#[cfg(target_arch = "wasm32")]
fn load_path(_: &str) -> Result<Plugin> {
fn load_path(_: &str) -> Result<(Plugin, Vec<u8>)> {
Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into()))
}
@@ -186,8 +193,9 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
Ok(v)
}
fn remove(_: &mut Session, p: &Value) -> Result<Value> {
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
s.unstore_plugin(id);
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
}
+5 -2
View File
@@ -89,8 +89,10 @@ pub const GPU_PREFERENCES: &[(&str, &str)] =
&[("automatic", "Automatic"), ("lowPower", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
/// Performance › Linux Display Server (`linuxDisplayServer`), read by the desktop app at startup:
/// `x11` opens the window through XWayland on a Wayland session, where dropped files arrive
/// (winit 0.30 has no Wayland drag and drop, #1151).
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] = &[("automatic", "Automatic"), ("x11", "X11 (XWayland)")];
/// (winit 0.30 has no Wayland drag and drop, #1151); `x11WithPen` does the same only when a pen is
/// attached as the app starts: under XWayland a drawing tablet's pen draws as a mouse (#2028).
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] =
&[("automatic", "Automatic"), ("x11", "X11 (XWayland)"), ("x11WithPen", "X11 (XWayland) When a Pen Is Attached")];
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
@@ -138,6 +140,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
p!("scaleStrokes", "General", "Options", "Scale Strokes & Effects", bool),
p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool),
p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool),
p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool),
// Selection & Anchor Display
p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")),
p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool),
+10 -3
View File
@@ -5,7 +5,7 @@
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_geom::Point;
use vectorcraft_geom::{Point, Rect};
use vectorcraft_pdf::{TileOrigin, TilingPage};
use super::fileio::pdf::pdf_error;
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Tiling",
[],
None,
"{origin: [x, y] (document point: the top-left corner of the first page's imageable area), artboard? (0-based: the artboard it is measured from; default the printed artboard containing origin, else the first; ignored with artboards: ignore)} | {reset: true} place the printed pages as the Print Tiling tool does, saved with the print settings (tileOrigin: {placed, x, y}, from the artboard's top-left corner; it replaces the placement, and tiles start there), as one undo step; reset → the placement places them again → {tileOrigin}",
"{origin: [x, y] (document point: the top-left corner of the first page's imageable area), artboard? (0-based: the artboard it is measured from; default the printed artboard containing origin, its edges included, else the first; ignored with artboards: ignore)} | {reset: true} place the printed pages as the Print Tiling tool does, saved with the print settings (tileOrigin: {placed, x, y}, from the artboard's top-left corner; it replaces the placement, and tiles start there), as one undo step; reset → the placement places them again → {tileOrigin}",
has_doc,
set_origin
),
@@ -77,6 +77,13 @@ fn point(p: &Value, key: &str) -> Option<Point> {
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
/// The first of the print `regions` holding `at`: one that holds it inside or on its left or top
/// edge, else one that holds it on its right or bottom edge or within a millionth of a point
/// outside an edge (the edges `Document::art_on_artboard` counts).
fn region_holding(regions: &[(Option<usize>, Rect)], at: Point) -> Option<&(Option<usize>, Rect)> {
regions.iter().find(|(_, r)| r.contains(at)).or_else(|| regions.iter().find(|(_, r)| vectorcraft_doc::artboard_holds(*r, at)))
}
fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let current = settings(SET, doc, &json!({}))?;
@@ -92,7 +99,7 @@ fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
let i = v.as_u64().ok_or_else(|| bad(SET, "artboard must be a 0-based index"))? as usize;
regions.iter().find(|(a, _)| a.is_none() || *a == Some(i)).ok_or_else(|| bad(SET, format!("artboard {i} doesn't print")))?
}
None => regions.iter().find(|(_, r)| r.contains(at)).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
None => region_holding(&regions, at).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
};
let corner = picked.1.origin();
TileOrigin { placed: true, x: at.x - corner.x, y: at.y - corner.y }
+52 -12
View File
@@ -6,11 +6,13 @@
//! model, background, anti-aliasing, room around the art). Shadows and outer glows only add the
//! effect: the image (with the object's own area knocked out) goes under the untouched vector
//! object. Effects that change the object itself (inner glow, feather, Gaussian blur) replace it
//! with the image. Expand Appearance makes its images with the same helpers.
//! with the image. An object whose only raster effect is a Rasterize of its own is replaced with
//! the image Object › Rasterize makes with that effect's options. Expand Appearance makes its
//! images with the same helpers.
//!
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
//! effects (Radial Blur, Unsharp Mask…): SVG export turns only the objects carrying one of those
//! into images the same way ([`flatten_pixel_effects`]).
//! effects (Radial Blur, Unsharp Mask…) or Rasterize: SVG export turns only the objects carrying
//! one of those into images the same way ([`flatten_pixel_effects`]).
use std::sync::Arc;
@@ -159,6 +161,12 @@ fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64, anti_alias
r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, supersample: true, ..Default::default() })
}
/// The note for pixels drawn since [`vectorcraft_render::decode_failures`] stood at `failed`, when
/// an image among them couldn't be decoded and so drew nothing; `None` otherwise.
pub(crate) fn undecoded_warning(failed: u64) -> Option<&'static str> {
(vectorcraft_render::decode_failures() > failed).then_some("an image couldn't be decoded and is missing from the picture")
}
/// Pixels per point of raster effects rendered as images (the document's raster effects
/// resolution).
pub(crate) fn effects_scale(doc: &Document) -> f64 {
@@ -283,11 +291,15 @@ fn below_images(d: &mut Document, n: &Node) -> Option<Vec<Node>> {
}
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d`
/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays
/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds
/// just them; with `members` too, a group's or layer's image leaves out its members' raster
/// effects (they get images of their own).
/// rendered at the document's raster effects resolution, or at a Rasterize effect's finer one (an
/// object whose only raster effect is its own Rasterize gets [`rasterize_effect_image`]'s image),
/// without `n`'s transparency (that stays on the object). With `below` (they all paint below it:
/// shadows and outer glows) the image holds just them; with `members` too, a group's or layer's
/// image leaves out its members' raster effects (they get images of their own).
pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: bool) -> Option<Node> {
if !below && let Some(image) = rasterize_effect_image(d, n) {
return Some(image);
}
let mut whole = n.clone();
(whole.opacity, whole.blend) = (1.0, Default::default());
whole.extras_mut().mask = None;
@@ -301,10 +313,37 @@ pub(crate) fn raster_image(d: &mut Document, n: &Node, below: bool, members: boo
strip_raster(&mut b, false);
b
});
let scale = effects_scale(d);
// A Rasterize effect at a finer resolution than the document's is rendered at its own.
let scale = raster_fx(n)
.iter()
.filter_map(|fx| if let RasterFx::Rasterize(r) = fx { r.ppi } else { None })
.fold(effects_scale(d), |s, ppi| s.max(ppi / 72.0));
effect_image(d, &whole, bare.as_ref(), scale)
}
/// The image Object › Rasterize makes of `n` with the options of `n`'s Effect › Rasterize, when
/// that is its only visible raster effect and is its own (not a fill's or a stroke's): made of `n`
/// without that effect and without its transparency, which stays on the object. `None` otherwise,
/// or when the image can't be made.
fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option<Node> {
let [RasterFx::Rasterize(r)] = raster_fx(n)[..] else { return None };
let at = n.appearance.effects.iter().position(|e| e.visible && e.id == effects::RASTERIZE)?;
let p = json!({
"ppi": r.ppi.unwrap_or(d.raster_effects_ppi),
"colorModel": r.color_model.id(),
"background": if r.white { "white" } else { "transparent" },
"antiAlias": if !r.anti_alias { "none" } else if r.hinted { "type" } else { "art" },
"clippingMask": r.clipping_mask,
"addAround": r.add_around,
});
let mut whole = n.clone();
whole.appearance.effects.remove(at);
(whole.opacity, whole.blend) = (1.0, Default::default());
whole.extras_mut().mask = None;
let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize").ok()?;
Some(image.into_node(d))
}
/// `v` (an object without its raster effects) replaced by `image` of the object with them: the
/// image takes its place with its id, name and transparency (a layer keeps the image as its only
/// member).
@@ -403,7 +442,8 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
enum Which {
/// Every raster effect (PDF).
All,
/// Objects with a Photoshop-style effect, which SVG can't draw (with all their raster effects).
/// Objects with a Photoshop-style effect or Rasterize, which SVG can't draw (with all their
/// raster effects).
Pixel,
}
@@ -412,7 +452,7 @@ fn imaged(n: &Node, which: Which) -> bool {
let fx = raster_fx(n);
match which {
Which::All => !fx.is_empty(),
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_))),
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))),
}
}
@@ -513,8 +553,8 @@ pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
}
/// A copy of `doc` in which the objects with Photoshop-style raster effects (Radial Blur, Unsharp
/// Mask…) are images as [`flatten_raster_effects`] makes them (SVG export: its filters draw the
/// other raster effects live), or `None` when there are none.
/// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its
/// filters draw the other raster effects live), or `None` when there are none.
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
flatten(doc, &mut FlattenReport::new(Which::Pixel, doc))
}
+1 -1
View File
@@ -697,7 +697,7 @@ pub fn skip_reason(s: &Session, st: &DocState) -> Option<&'static str> {
if !st.is_dirty() {
return Some("no unsaved changes");
}
if st.recovery.as_ref().is_some_and(|c| Arc::ptr_eq(&c.doc, &st.doc)) {
if st.recovery.as_ref().is_some_and(|c| st.same_content(&c.doc)) {
return Some("unchanged since its last recovery copy");
}
if s.prefs.recovery_off_for_complex && object_count(&st.doc) > COMPLEX_OBJECTS {
+8 -1
View File
@@ -259,9 +259,16 @@ fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?;
// Symbol instances are measured by their art.
let symbols = st.doc.symbol_boxes();
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation)
.into_iter()
.filter(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds()).is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.center())))
.filter(|id| {
st.doc
.node(*id)
.and_then(|n| n.geometric_bounds_with(&symbols))
.is_some_and(|b| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center()))
})
.collect();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
+70 -5
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -255,6 +255,7 @@ pub(crate) struct CharChange {
char_align: Option<vectorcraft_doc::CharAlign>,
proportional_metrics: Option<bool>,
digits: Option<vectorcraft_doc::Digits>,
font_axes: Option<AxisChange>,
}
/// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in.
@@ -270,6 +271,60 @@ pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result<Option<vectorcraft_do
}))
}
/// Variable Font settings to change: by axis tag, a value in the axis's units, or `None` to go
/// back to the style's own setting of that axis.
pub(crate) type AxisChange = std::collections::BTreeMap<String, Option<f64>>;
/// `fontAxes: {"wght": 650, "wdth": null, …}` → the Variable Font settings to change. A tag is four
/// printable ASCII characters (`wght`); a value is a number, or null.
pub(crate) fn font_axes_param(p: &Value, cmd: &str) -> Result<Option<AxisChange>> {
let Some(v) = p.get("fontAxes").filter(|v| !v.is_null()) else { return Ok(None) };
let obj = v.as_object().ok_or_else(|| bad(cmd, "`fontAxes` must be an object of axis tags and values, such as {\"wght\": 650}"))?;
let mut out = AxisChange::new();
for (tag, value) in obj {
if tag.len() != 4 || !tag.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
return Err(bad(cmd, format!("`{tag}` is not an axis tag: a tag is four characters, such as \"wght\"")));
}
let value = match value {
Value::Null => None,
v => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("the value of axis `{tag}` must be a number or null")))?),
};
out.insert(tag.clone(), value);
}
Ok(Some(out))
}
/// Change the Variable Font settings of `st` by `change`. With its font available, a value is
/// clamped to its axis's range, a tag its font has no axis for is left out, and a value equal to
/// the style's own setting is removed; with the font missing, the values are kept as given.
pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) {
let db = vectorcraft_text::FontDb::global();
let face = match db.resolve(&st.font_family, &st.font_style) {
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
Some(db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).unwrap_or(f))
}
_ => None,
};
let axes = face.as_ref().map(|f| f.axes()).unwrap_or_default();
for (tag, value) in change {
let Some(v) = value else {
st.font_axes.remove(tag);
continue;
};
let Some(face) = &face else {
st.font_axes.insert(tag.clone(), *v);
continue;
};
let Some(axis) = axes.iter().find(|a| a.tag.as_slice() == tag.as_bytes()) else { continue };
let v = v.clamp(f64::from(axis.min.min(axis.max)), f64::from(axis.max.max(axis.min)));
if face.axis_value(axis.tag).is_some_and(|own| f64::from(own) == v) {
st.font_axes.remove(tag);
} else {
st.font_axes.insert(tag.clone(), v);
}
}
}
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
@@ -358,6 +413,7 @@ impl CharChange {
char_align: char_align_param(p, cmd)?,
proportional_metrics: flag("proportionalMetrics"),
digits: digits_param(p, cmd)?,
font_axes: font_axes_param(p, cmd)?,
};
if c.size.is_some_and(|v| v <= 0.0) {
return Err(bad(cmd, "size must be positive"));
@@ -388,6 +444,7 @@ impl CharChange {
&& self.char_align.is_none()
&& self.proportional_metrics.is_none()
&& self.digits.is_none()
&& self.font_axes.is_none()
}
fn localize(&mut self, t: &TextObject, cmd: &str) -> Result<()> {
@@ -416,9 +473,13 @@ impl CharChange {
if let Some(v) = &self.style {
st.font_style = v.clone();
}
// Another font: none of the old one's versions.
// Another font: none of the old one's versions or Variable Font settings.
if self.font.is_some() || self.style.is_some() {
st.font_version = None;
st.font_axes.clear();
}
if let Some(v) = &self.font_axes {
change_font_axes(st, v);
}
if let Some(v) = self.size {
st.size = v;
@@ -484,7 +545,8 @@ impl CharChange {
}
/// Type › Enable Missing Glyph Protection: after a font change, the characters of `runs` their new
/// font has no glyph for, but their font `before` the change had, keep that font.
/// font has no glyph for, but their font `before` the change had, keep that font: its family,
/// style, version and Variable Font settings.
pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>) {
fn chars(runs: &[TextRun]) -> impl Iterator<Item = (usize, char, &CharStyle)> {
runs.iter()
@@ -503,7 +565,8 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
.or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()));
face.as_ref().map(|f| f.covers(c))
};
// Byte ranges that keep a font (family, style).
// Byte ranges that keep a font (family, style, version and Variable Font settings).
let font = |st: &CharStyle| (st.font_family.clone(), st.font_style.clone(), st.font_version.clone(), st.font_axes.clone());
let mut keep: Vec<(usize, usize, &CharStyle)> = vec![];
for ((at, c, old), (_, _, new)) in chars(before).zip(chars(runs)) {
let changed = (&old.font_family, &old.font_style) != (&new.font_family, &new.font_style);
@@ -512,7 +575,7 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
}
let end = at + c.len_utf8();
match keep.last_mut() {
Some((_, e, st)) if *e == at && (&st.font_family, &st.font_style) == (&old.font_family, &old.font_style) => *e = end,
Some((_, e, st)) if *e == at && font(st) == font(old) => *e = end,
_ => keep.push((at, end, old)),
}
}
@@ -520,6 +583,8 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
edit::style_range(runs, a, b, |st| {
st.font_family.clone_from(&old.font_family);
st.font_style.clone_from(&old.font_style);
st.font_version.clone_from(&old.font_version);
st.font_axes.clone_from(&old.font_axes);
});
}
}
+118 -7
View File
@@ -58,7 +58,7 @@ macro_rules! style_cmds {
concat!($p, ".new"),
concat!("New ", $what, " Style"),
&["Window", "Type", $menu],
"{name?, attrs?: {…} (default: selected text), id?, start?: byte, end?: byte} → {name}; a nonempty character range samples its first selected character; start alone keeps insertion affinity; paragraph styles sample start",
"{name? (trimmed, as rename trims `to`), attrs?: {…} (default: selected text), id?, start?: byte, end?: byte} → {name}; a nonempty character range samples its first selected character; start alone keeps insertion affinity; paragraph styles sample start",
|s, p| new(s, p, $kind),
true,
),
@@ -141,6 +141,7 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
let mut m = to_attrs(v);
for (k, x) in [
("font_version", json!(v.font_version)),
("fontAxes", json!(v.font_axes)),
("leading", json!(v.leading)),
("kerning", json!(v.kerning)),
("features", json!(v.features)),
@@ -176,6 +177,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
("leading_model", json!(v.leading_model)),
("burasagari", json!(v.burasagari)),
("kinsoku", json!(v.kinsoku)),
("roman_hanging", json!(v.roman_hanging)),
] {
m.insert(k.into(), x);
}
@@ -250,6 +252,21 @@ fn restyle<T: Serialize + DeserializeOwned + Clone>(
serde_json::from_value(Value::Object(m)).unwrap_or_else(|_| cur.clone())
}
/// `st`, written over `before` by a character style's `attrs`: when its font or style changed,
/// it keeps none of the old one's versions or Variable Font settings unless the style sets them.
fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map<String, Value>) -> CharStyle {
if st.font_family == before.font_family && st.font_style == before.font_style {
return st;
}
if st.font_version.as_ref().is_some_and(|v| attrs.get("font_version") != Some(&json!(v))) {
st.font_version = None;
}
if !st.font_axes.is_empty() && attrs.get("fontAxes") != Some(&json!(st.font_axes)) {
st.font_axes.clear();
}
st
}
/// Every text object id in the document.
fn all_text(d: &Document) -> Vec<NodeId> {
let mut v = vec![];
@@ -293,7 +310,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
Kind::Char => {
for r in &mut t.runs {
if r.style.style_name == target {
r.style = restyle(&r.style, char_attrs, old, new);
r.style = font_change(&r.style, restyle(&r.style, char_attrs, old, new), new);
changed = true;
}
}
@@ -415,10 +432,13 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let d = &s.doc()?.doc;
let base = if kind == Kind::Char { "Character Style" } else { "Paragraph Style" };
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
Some(n) => n.trim().to_string(),
None => (1..).map(|i| format!("{base} {i}")).find(|n| kind.attrs(d, n).is_none()).unwrap_or_else(|| base.to_string()),
};
if name.trim().is_empty() || kind.attrs(d, &name).is_some() {
if name.is_empty() {
return Err(bad(&c, "give the style a name"));
}
if kind.attrs(d, &name).is_some() {
return Err(bad(&c, format!("a style named `{name}` already exists")));
}
let n2 = name.clone();
@@ -447,7 +467,7 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
// Characters: Normal then the style over the defaults (clear) or over the current attributes.
let char_fn = |cur: &CharStyle| -> Result<CharStyle> {
let base = if clear { with_attrs(&CharStyle::default(), &normal, &c)? } else { cur.clone() };
let mut st = with_attrs(&base, &attrs, &c)?;
let mut st = font_change(&base, with_attrs(&base, &attrs, &c)?, &attrs);
st.style_name = style_name.clone();
Ok(st)
};
@@ -535,10 +555,12 @@ fn duplicate(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let name = name_param(p, kind, "duplicate")?.to_string();
let d = &s.doc()?.doc;
let attrs = kind.attrs(d, &name).ok_or_else(|| bad(&kind.cmd("duplicate"), format!("no style `{name}`")))?;
// `new` trims the name, so the copy is named from the trimmed one.
let stem = name.trim();
let copy = (1..)
.map(|i| if i == 1 { format!("{name} copy") } else { format!("{name} copy {i}") })
.map(|i| if i == 1 { format!("{stem} copy") } else { format!("{stem} copy {i}") })
.find(|n| kind.attrs(d, n).is_none())
.unwrap_or_else(|| format!("{name} copy"));
.unwrap_or_else(|| format!("{stem} copy"));
new(s, &json!({ "name": copy, "attrs": attrs }), kind)
}
@@ -671,6 +693,29 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill);
}
/// New Character Style and New Paragraph Style trim the name they are given, as a rename
/// trims `to`; the trimmed name is the one compared with the styles there are.
#[test]
fn new_styles_trim_their_names_as_a_rename_does() {
let (mut s, _) = session_with_text("Body text");
for p in ["charStyle", "paraStyle"] {
assert_eq!(s.execute(&format!("{p}.new"), &json!({"name": " Body \t"})).unwrap()["name"], "Body", "{p}");
let list = s.execute(&format!("{p}.list"), &json!({})).unwrap();
let names: Vec<&str> = list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect();
assert_eq!(names[1..], ["Body"], "{p}");
let e = s.execute(&format!("{p}.new"), &json!({"name": " Body"})).unwrap_err().to_string();
assert!(e.contains("a style named `Body` already exists"), "{p}: {e}");
let e = s.execute(&format!("{p}.new"), &json!({"name": " \t "})).unwrap_err().to_string();
assert!(e.contains("give the style a name"), "{p}: {e}");
assert_eq!(s.execute(&format!("{p}.duplicate"), &json!({"name": "Body"})).unwrap()["name"], "Body copy", "{p}");
}
// A style whose name has spaces around it, as a saved file can hold, is copied under the
// trimmed name, numbered past the copies there are.
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: " Old ".into(), attrs: Map::new() });
s.execute("charStyle.new", &json!({"name": "Old copy"})).unwrap();
assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2");
}
/// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no
/// mojikumi) are still attributes a style can set, and a style captured with one applies to
/// text at the default, is duplicated and comes back out of a library.
@@ -827,6 +872,72 @@ mod tests {
assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"}));
}
/// A style captured from type without Variable Font settings sets none explicitly (`{}`):
/// applying it clears type's settings, and redefining it from type whose settings were reset
/// resets the type that uses it.
#[test]
fn variable_font_settings_are_captured_explicitly() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let (mut s, source) = session_with_text("Source");
let linked = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Linked"})).unwrap()["id"].as_u64().unwrap();
let ids = [source, linked];
s.execute("text.setStyle", &json!({"ids": ids, "font": TWO_AXIS_FAMILY})).unwrap();
s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap();
assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("fontAxes"), Some(&json!({})));
s.execute("text.setFormat", &json!({"ids": [linked], "fontAxes": {"wght": 650}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap();
let wght = |s: &Session| ids.map(|id| runs(s, id)[0].style.font_axes.get("wght").copied());
assert_eq!(wght(&s), [None, None], "applying the style clears the settings");
for (set, want) in [(json!(650), Some(650.0)), (json!(null), None)] {
s.execute("text.setFormat", &json!({"ids": [source], "fontAxes": {"wght": set}})).unwrap();
s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap();
assert_eq!(wght(&s), [want, want], "redefined from {set}");
}
}
/// A style that changes the font, applied or redefined, leaves none of the old font's
/// version and Variable Font settings, unless the style sets them.
#[test]
fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let (mut s, id) = session_with_text("Source");
let set_variable = |s: &mut Session| {
s.execute("text.setStyle", &json!({"ids": [id], "font": TWO_AXIS_FAMILY})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id], "fontAxes": {"wght": 650}})).unwrap();
s.edit("version", |d, _| {
if let Some(t) = text_mut(d, NodeId(id)) {
t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into()));
}
Ok(())
})
.unwrap();
};
let font = |s: &Session| {
let st = runs(s, id)[0].style.clone();
(st.font_family, st.font_version, st.font_axes.get("wght").copied())
};
set_variable(&mut s);
s.execute("charStyle.new", &json!({"name": "Plain", "attrs": {"font_family": "Inter"}})).unwrap();
s.execute("charStyle.new", &json!({"name": "Same", "attrs": {"size": 20}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Same", "id": id})).unwrap();
assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), Some("Version 2.000".to_string()), Some(650.0)), "the same font keeps them");
s.execute("charStyle.apply", &json!({"name": "Plain", "id": id})).unwrap();
assert_eq!(font(&s), ("Inter".to_string(), None, None));
// A style that sets the settings keeps its own.
s.execute("charStyle.new", &json!({"name": "Heavy", "attrs": {"font_family": TWO_AXIS_FAMILY, "fontAxes": {"wght": 600}}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Heavy", "id": id})).unwrap();
assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), None, Some(600.0)));
// Redefined to another font: its users' settings and versions go too.
s.execute("charStyle.new", &json!({"name": "Sans", "attrs": {"font_family": TWO_AXIS_FAMILY}})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Sans", "id": id})).unwrap();
set_variable(&mut s);
assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Sans"));
s.execute("charStyle.setAttrs", &json!({"name": "Sans", "attrs": {"font_family": "Inter"}})).unwrap();
assert_eq!(font(&s), ("Inter".to_string(), None, None));
}
#[test]
fn auto_leading_new_distinguishes_unset_attributes() {
let (mut s, id) = session_with_text("Title");
+331 -1
View File
@@ -21,6 +21,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_multi,
create
),
cmd!(
"text.thread.add",
"Thread Text",
[],
None,
"{from, to?, rect?: [x, y, width, height], at?: [x, y]} thread area type `from` (a click on its out port) into one more frame, put after it in its thread: closed path or area type `to`, a new rectangle `rect`, or a new frame of `from`'s shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected",
has_doc,
add
),
cmd!(
"text.thread.releaseSelection",
"Release Selection",
@@ -128,6 +137,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
}
}
}
let optical = if live.len() >= 2 { optical_margin(before, doc, &live) } else { None };
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
let refs: Vec<&TextObject> = frames.iter().collect();
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, &paras);
@@ -136,10 +146,12 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
let mut next = (**t).clone();
next.runs = part.runs;
next.set_paragraph_styles(part.paras);
if next.runs != t.runs || next.para != t.para || next.paras != t.paras {
let optical = optical.unwrap_or(t.optical_margin);
if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin {
t.runs = next.runs;
t.para = next.para;
t.paras = next.paras;
t.optical_margin = optical;
refresh_bounds(t);
}
}
@@ -151,6 +163,44 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
doc.text_threads = kept;
}
/// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes:
/// that of the first frame whose setting changed between `before` and `doc`, else the first frame's.
fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option<bool> {
let now = |id: &NodeId| text_of(doc, *id).map(|t| t.optical_margin);
let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.optical_margin) != now(id)));
changed.or(live.first()).and_then(now)
}
/// Give every frame of each thread the first frame's Optical Margin Alignment setting, without
/// re-flowing the story: for a document restored from a file as it was saved.
pub(crate) fn share_optical_margin(doc: &mut Document) {
for thread in doc.text_threads.clone() {
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
let Some(on) = optical_margin(doc, doc, &live) else { continue };
for id in &live {
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
&& t.optical_margin != on
{
t.optical_margin = on;
refresh_bounds(t);
}
}
}
}
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = ids.to_vec();
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| ids.contains(id))) {
for id in thread {
if !out.contains(id) && text_of(d, *id).is_some() {
out.push(*id);
}
}
}
out
}
/// A closed path converted to an empty area-type frame with `style`.
fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle) -> Option<Node> {
let NodeKind::Path { path, .. } = &n.kind else { return None };
@@ -205,6 +255,92 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// `text.thread.add`: the out port's loaded text, placed.
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.thread.add";
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let from = checked_id(s, given("from").unwrap_or(&Value::Null), C)?;
let d = &s.doc()?.doc;
let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type"))?;
let style = src.first_style();
// The frame to thread into: an existing object (`to`), or a new one beside `from`.
let (to, new) = match (given("to"), given("rect"), given("at")) {
(Some(v), None, None) => {
let to = checked_id(s, v, C)?;
let parent = d.parent_of(to).and_then(|i| d.node(i));
let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone()).is_some());
if to == from || !frame || parent.is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) {
return Err(bad(C, "`to` must be a closed path or area type other than `from`"));
}
if d.text_threads.iter().any(|t| t.contains(&to)) {
return Err(bad(C, "`to` is already threaded"));
}
(to, None)
}
(None, Some(v), None) => {
let [x, y, w, h] = finite_numbers(v)
.filter(|[_, _, w, h]| *w > 0.0 && *h > 0.0)
.ok_or_else(|| bad(C, "`rect` must be [x, y, width, height] with a positive size"))?;
let frame = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, w, h));
(NodeId(0), Some(empty_frame(src, style.clone(), frame, vectorcraft_geom::Affine::translate((x, y)))))
}
(None, None, Some(v)) => {
let [x, y] = finite_numbers(v).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
let TextKind::Area { frame } = &src.kind else { return Err(bad(C, "`from` must be area type")) };
let b = frame.bounds().map(|b| src.xf.transform_rect_bbox(b)).ok_or_else(|| bad(C, "`from` has an empty frame"))?;
let xf = vectorcraft_geom::Affine::translate((x - b.x0, y - b.y0)) * src.xf;
(NodeId(0), Some(empty_frame(src, style.clone(), frame.clone(), xf)))
}
_ => return Err(bad(C, "give one of `to`, `rect` or `at`")),
};
let appearance = d.node(from).map(|n| n.appearance.clone());
let id = s.edit("Thread Text", |d, sel| {
let to = match new {
Some(t) => {
let (parent, idx, _) = d.position(from).ok_or(EngineError::NoNode(from))?;
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Text(Box::new(t)));
if let Some(a) = appearance {
n.appearance = a;
}
d.insert(parent, idx + 1, n)?;
id
}
None => {
if text_of(d, to).is_none() {
let n = d.node(to).cloned().ok_or(EngineError::NoNode(to))?;
*d.node_mut(to).ok_or(EngineError::NoNode(to))? = frame_from_path(&n, style).ok_or_else(|| bad(C, "not a closed path"))?;
}
to
}
};
match d.text_threads.iter_mut().find(|t| t.contains(&from)) {
Some(t) => {
let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| i + 1);
t.insert(at, to);
}
None => d.text_threads.push(vec![from, to]),
}
// Force a re-flow of the thread on this edit: the edit leaves existing area type `to`
// unchanged.
touch(d, to);
sel.set([from, to]);
Ok(to)
})?;
Ok(json!({ "id": id.0 }))
}
/// An empty area-type frame `frame` placed by `xf`, with the type options of `src`.
fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, frame: vectorcraft_geom::PathData, xf: vectorcraft_geom::Affine) -> TextObject {
let mut t = src.clone();
t.runs = vec![TextRun { text: String::new(), style, inline: None }];
t.set_all_paras(src.para_at(0).clone());
t.kind = TextKind::Area { frame };
t.xf = xf;
refresh_bounds(&mut t);
t
}
/// Give a node a fresh allocation so [`reflow`] sees its thread as changed.
fn touch(d: &mut Document, id: NodeId) {
if let Some(n) = d.node_mut(id) {
@@ -413,6 +549,91 @@ mod tests {
}
}
fn new_id(v: Value) -> u64 {
v["id"].as_u64().unwrap()
}
fn threads(s: &Session) -> Vec<Vec<u64>> {
s.doc().unwrap().doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect()).collect()
}
/// Bounds of area type `id`'s frame on the page.
fn frame_box(s: &Session, id: u64) -> vectorcraft_geom::Rect {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
t.xf.transform_rect_bbox(frame.bounds().unwrap())
}
/// #2572: the text loaded from an out port threads into a closed path, into a new frame of the
/// first one's shape and size at a point, and into a new rectangle, each in one undo step.
#[test]
fn add_threads_into_a_path_a_frame_at_a_point_and_a_rectangle() {
let (mut s, a, b) = setup();
assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap()), b);
assert_eq!(threads(&s), [[a, b]]);
assert!(!text(&s, b).is_empty(), "the overflow flowed into the path");
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(b)]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert!(matches!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind, NodeKind::Path { .. }), "undo gives the path back");
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200]})).unwrap());
assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the first frame's size, its top-left at the click");
let d = new_id(s.execute("text.thread.add", &json!({"from": c, "rect": [10, 100, 300, 150]})).unwrap());
assert_eq!(frame_box(&s, d), vectorcraft_geom::Rect::new(10.0, 100.0, 310.0, 250.0));
assert_eq!(threads(&s), [[a, c, d]], "each new frame comes after the one it was threaded from");
assert_eq!(format!("{}{}{}", text(&s, a), text(&s, c), text(&s, d)), TEXT.repeat(4));
assert!(!text(&s, d).is_empty());
// A frame threaded from the middle of a thread goes in after it.
let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0]})).unwrap());
assert_eq!(threads(&s), [[a, e, c, d]]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(NodeId(e)).is_none());
assert_eq!(threads(&s), [[a, c, d]]);
}
/// Area type threaded into from an out port takes the overflow at once, before its own text.
#[test]
fn add_into_area_type_flows_the_story_into_it() {
let (mut s, a, _) = setup();
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap());
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
assert_eq!(threads(&s), [[a, c]]);
assert_eq!(format!("{}{}", text(&s, a), text(&s, c)), format!("{}Own text.", TEXT.repeat(4)));
assert!(text(&s, c).len() > "Own text.".len(), "the overflow flowed into the frame: {:?}", text(&s, c));
}
#[test]
fn add_refuses_bad_params_and_leaves_the_document_alone() {
let (mut s, a, b) = setup();
let line = new_id(s.execute("shape.line", &json!({"x1": 0, "y1": 280, "x2": 50, "y2": 290})).unwrap());
let before = s.doc().unwrap().doc.clone();
for p in [
json!({"to": b}),
json!({"from": b, "at": [0, 0]}),
json!({"from": 9999, "at": [0, 0]}),
json!({"from": -1, "at": [0, 0]}),
json!({"from": a}),
json!({"from": a, "to": b, "at": [0, 0]}),
json!({"from": a, "to": a}),
json!({"from": a, "to": line}),
json!({"from": a, "to": 9999}),
json!({"from": a, "at": [0]}),
json!({"from": a, "at": ["1", 2]}),
json!({"from": a, "rect": [0, 0, -10, 10]}),
json!({"from": a, "rect": [0, 0, 10, 0]}),
json!({"from": a, "rect": [0, 0, 10]}),
json!({"from": a, "rect": "big"}),
] {
assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted");
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document");
}
s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap();
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "x", "area": {"width": 50, "height": 20}})).unwrap());
assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded");
}
#[test]
fn release_and_remove() {
let (mut s, a, b) = setup();
@@ -428,4 +649,113 @@ mod tests {
assert_eq!(text(&s, b), "");
assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame");
}
/// A story of one-line paragraphs that each start with a quotation mark, threaded from area
/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for.
fn quoted_thread() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let story = "“Quoted.”\n".repeat(20);
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert!(text(&s, b).starts_with('“'));
(s, a, b)
}
/// Frame `id`'s Optical Margin Alignment setting, and whether its glyphs start left of its frame
/// (its lines' opening quotation marks hang).
fn optical(s: &Session, id: u64) -> (bool, bool) {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
(t.optical_margin, t.cached_bounds.unwrap().x0 < frame.bounds().unwrap().x0 - 1.0)
}
/// Optical Margin Alignment is a setting of the whole thread: Type › Optical Margin Alignment
/// in any frame sets it in every frame of the thread, in one undo step, and the lines in every
/// frame hang. Typing keeps it, and frames taken out of the thread keep it.
#[test]
fn optical_margin_alignment_is_set_for_every_frame_of_a_thread() {
let (mut s, a, b) = quoted_thread();
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let r = s.execute("type.opticalMarginAlignment", &json!({})).unwrap();
assert_eq!(r, json!({"on": true, "ids": [a, b]}));
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)), "the second frame's lines hang too");
// Turned off from the second frame, it is off in both, and one undo turns it back on in both.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false);
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
// Typing in the first frame re-flows the story and keeps the setting.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "“New.”\n"})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
// Any edit that sets one frame sets the thread.
s.edit("Set", |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") };
t.optical_margin = false;
refresh_bounds(t);
Ok(())
})
.unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("edit.undo", &json!({})).unwrap();
// Released or unthreaded, each frame keeps it.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, true));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
}
/// Threading type into a thread gives the joined thread the setting of its first frame.
#[test]
fn a_thread_takes_its_first_frames_optical_margin_alignment() {
let (mut s, a, b) = quoted_thread();
s.execute("text.thread.remove", &json!({})).unwrap();
s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false));
// Threaded again in stacking order: the second frame takes the first one's setting.
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false));
// From `b`'s out port into `a`: `a` joins `b`'s thread after it and takes its setting.
s.execute("text.thread.add", &json!({"from": b, "to": a})).unwrap();
assert_eq!(threads(&s), [[b, a]]);
assert_eq!((optical(&s, a).0, optical(&s, b).0), (false, false));
// A new frame threaded from a frame takes the thread's setting.
s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap();
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 10]})).unwrap());
assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]);
}
/// A file saved with the frames of one thread set differently opens with the first frame's
/// setting in every frame: a native file, and an EPS file restored from the document it carries.
#[test]
fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() {
let (mut s, a, b) = quoted_thread();
for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] {
for (id, on) in [(a, first), (b, !first)] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.optical_margin = on;
}
let bytes = if name.ends_with(".eps") {
let r = s.execute("document.exportEps", &json!({})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
} else {
vectorcraft_format::save(&s.doc().unwrap().doc, false)
};
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
assert_eq!(threads(&s), [[a, b]], "{name}");
assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}");
}
}
}
+131 -6
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Optical Margin Alignment",
["Type"],
None,
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}",
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}",
has_selection,
optical_margin_alignment
),
@@ -107,9 +107,9 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
}
/// Type › Optical Margin Alignment: set `on` (without it, the opposite of the first object's
/// setting) on every selected text object, in one undo step.
/// setting) on every selected text object and every frame of their threads, in one undo step.
fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
let ids = text_targets(s, p, "Optical Margin Alignment")?;
let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Optical Margin Alignment")?);
let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.optical_margin),
_ => None,
@@ -315,6 +315,17 @@ pub(crate) fn with_bounds(s: &Session, id: Option<NodeId>, mut result: Value) ->
/// (as [`with_overflow`] finds it): the text past the end of their frames doesn't print. It names
/// the first 20 ids and counts the rest; `None` when `ids` is empty.
pub(crate) fn overflow_warning(ids: &[NodeId]) -> Option<String> {
overflow_note(ids, "doesn't print")
}
/// [`overflow_warning`] as an export that draws type gives it: the text past the end of the frames
/// isn't drawn in the export.
pub(crate) fn export_overflow_warning(ids: &[NodeId]) -> Option<String> {
overflow_note(ids, "isn't drawn in the export")
}
/// [`overflow_warning`], saying that the text past the end of the frames `left_out`.
fn overflow_note(ids: &[NodeId], left_out: &str) -> Option<String> {
const NAMED: usize = 20;
let (named, rest) = ids.split_at(ids.len().min(NAMED));
let mut list = named.iter().map(|i| i.0.to_string()).collect::<Vec<_>>().join(", ");
@@ -323,8 +334,8 @@ pub(crate) fn overflow_warning(ids: &[NodeId]) -> Option<String> {
}
match ids {
[] => None,
[_] => Some(format!("1 text object doesn't fit its frame: the text past the end of the frame doesn't print (id {list})")),
_ => Some(format!("{} text objects don't fit their frames: the text past the end of each frame doesn't print (ids {list})", ids.len())),
[_] => Some(format!("1 text object doesn't fit its frame: the text past the end of the frame {left_out} (id {list})")),
_ => Some(format!("{} text objects don't fit their frames: the text past the end of each frame {left_out} (ids {list})", ids.len())),
}
}
@@ -566,9 +577,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &style {
st.font_style = f.clone();
}
// Another font: none of the old one's versions.
// Another font: none of the old one's versions or Variable Font settings.
if font.is_some() || style.is_some() {
st.font_version = None;
st.font_axes.clear();
}
if let Some(v) = size {
st.size = v;
@@ -602,10 +614,12 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(f) = &font {
st.font_family = f.clone();
st.font_version = None;
st.font_axes.clear();
}
if let Some(f) = &style {
st.font_style = f.clone();
st.font_version = None;
st.font_axes.clear();
}
if let Some(v) = size {
st.size = v.clamp(0.1, 1296.0);
@@ -1047,6 +1061,43 @@ mod area_tests {
assert_eq!(old.hyphenation, HyphenationOptions::default());
}
/// Paragraph panel menu › Roman Hanging Punctuation: `text.setFormat {romanHangingPunctuation}`
/// sets it per paragraph in one undo step, the opening quotation mark then hangs left of the
/// frame, it is saved only when on, and paragraph styles carry it.
#[test]
fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s
.execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted”\n“Two”", "area": {"width": 300, "height": 200}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("text"),
};
let left = |s: &Session| text(s).cached_bounds.unwrap().x0;
assert!(!text(&s).para_at(0).roman_hanging);
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("roman_hanging").is_none());
let inside = left(&s);
s.execute("select.set", &json!({"ids": [id]})).unwrap();
// Only the first paragraph (the caret's range).
s.execute("text.setFormat", &json!({"romanHangingPunctuation": true, "start": 0, "end": 0})).unwrap();
assert!(text(&s).para_at(0).roman_hanging && !text(&s).para_at(1).roman_hanging);
assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s));
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["roman_hanging"], true);
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap();
assert!(back.roman_hanging);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!text(&s).para_at(0).roman_hanging);
assert!((left(&s) - inside).abs() < 1e-9);
// A paragraph style carries it.
s.execute("paraStyle.new", &json!({"name": "Hung", "attrs": {"roman_hanging": true}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Hung", "id": id})).unwrap();
assert!(text(&s).para_at(1).roman_hanging);
}
/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts
/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing
/// carry them; params override; the values are journaled; imported text keeps the Roman
@@ -1237,6 +1288,80 @@ mod area_tests {
assert_eq!(set, [("صفحة ".to_string(), Digits::Default), ("12".to_string(), Digits::Farsi)]);
}
/// Variable Font settings (Character panel › Variable Font): a character attribute, saved only
/// when set, each value clamped to its axis's range, and cleared by another font or style.
#[test]
fn variable_font_settings_are_a_character_attribute_saved_only_when_set() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font};
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "lIn lIn", "font": FAMILY, "size": 40})).unwrap()["id"].as_u64().unwrap();
let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.runs.clone(),
_ => panic!("text"),
};
let saved = |s: &Session| serde_json::to_value(&runs(s)[0].style).unwrap().get("fontAxes").cloned();
let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[NodeId(id)], false).unwrap().width();
assert_eq!(saved(&s), None);
let regular = width(&s);
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
assert_eq!(saved(&s), Some(json!({"wght": 650.0})));
assert!(width(&s) > regular * 1.1, "the type is set heavier and wider: {regular} -> {}", width(&s));
// Past the axis's range: its maximum; an axis the font hasn't: left out; others kept.
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000, "opsz": 12, "wdth": 80}})).unwrap();
assert_eq!(saved(&s), Some(json!({"wdth": 80.0, "wght": 700.0})));
// Null goes back to the style's own setting, and so does a value equal to it.
s.execute("text.setFormat", &json!({"fontAxes": {"wght": null}})).unwrap();
assert_eq!(saved(&s), Some(json!({"wdth": 80.0})));
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 100}})).unwrap();
assert_eq!(saved(&s), None);
assert!((width(&s) - regular).abs() < 1e-6);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(saved(&s), Some(json!({"wdth": 80.0})));
// Characters selected with the Type tool: only the range takes it.
s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "fontAxes": {"wght": 550}})).unwrap();
let set: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.font_axes.clone())).collect();
let axes = |v: &[(&str, f64)]| v.iter().map(|(t, v)| (t.to_string(), *v)).collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(set, [("lIn ".to_string(), axes(&[("wdth", 80.0)])), ("lIn".to_string(), axes(&[("wdth", 80.0), ("wght", 550.0)]))]);
// A saved file keeps them.
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
assert_eq!(t.runs, runs(&s));
// Another style or font clears them: their settings were the old face's.
s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "style": "Bold"})).unwrap();
assert_eq!(runs(&s).last().map(|r| (r.style.font_style.as_str(), r.style.font_axes.is_empty())), Some(("Bold", true)));
s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap();
s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap();
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
// A static font has no axes to set.
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
assert_eq!(saved(&s), None);
// A missing font keeps them as given, for when it is installed.
s.execute("text.setStyle", &json!({"font": "No Such Variable Sans"})).unwrap();
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 2000}})).unwrap();
assert_eq!(saved(&s), Some(json!({"wght": 2000.0})));
for (bad, says) in
[(json!({"weight": 650}), "not an axis tag"), (json!({"wght": "heavy"}), "must be a number"), (json!(650), "must be an object")]
{
let err = s.execute("text.setFormat", &json!({"fontAxes": bad})).unwrap_err().to_string();
assert!(err.contains(says), "{err}");
}
// The axes and the setting of a style, as the sliders show them.
let list = s.execute("text.fontList", &json!({"family": FAMILY, "style": "Bold"})).unwrap();
assert_eq!(
list["axes"],
json!([
{"tag": "wght", "name": "Weight", "min": 400.0, "default": 400.0, "max": 700.0, "value": 700.0, "hidden": false},
{"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false},
])
);
assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([]));
}
#[test]
fn new_type_is_composed_with_line_end_half_width_punctuation() {
use vectorcraft_doc::Mojikumi;
+123 -59
View File
@@ -5,8 +5,9 @@
//! variable, or a graph root to a `graph` variable (CSV data), put a row of values in
//! each dataset, and `dataset.select` swaps the whole document to that row in one undo
//! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name`
//! then variable names); import also reads tab-separated files and files without the
//! `name` column. Agents reach every command through `run_command`, like all engine
//! then variable names); import also reads tab-separated files, files without the
//! `name` column, and header cells that define new variables (`#` for visibility, `@` for
//! image, no prefix for text). Agents reach every command through `run_command`, like all engine
//! commands.
use std::collections::{BTreeMap, BTreeSet};
@@ -29,6 +30,20 @@ const MAX_CSV_BYTES: usize = 1_048_576;
const MAX_CSV_ROWS: usize = 1_000;
const MAX_CSV_COLS: usize = 256;
/// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options),
/// Make … Dynamic and an import whose header would define a variable; binding, unbinding and the
/// other dataset commands still run.
fn variables_unlocked(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|st| st.doc.variables.locked) { Err("variables are locked".into()) } else { Ok(()) }
}
/// Make … Dynamic needs a selection, and defines a variable, which Lock Variables refuses.
fn selection_and_unlocked(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
variables_unlocked(s)
}
fn var_name(p: &Value, cmd: &str) -> Result<String> {
named(str_param(p, "name"), "name", cmd)
}
@@ -61,9 +76,8 @@ fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) ->
}
}
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
/// layer is not the document's to rewrite; a text variable only drives type; an image variable
/// only drives an image object.
/// Refuse what a variable of `kind` can't be bound to: art that is locked or on a hidden or locked
/// layer; a text variable only drives type; an image variable only drives an image object.
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
for id in ids {
match doc.node(*id) {
@@ -233,7 +247,21 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
json!({"name": v.name, "kind": v.kind.label(), "bindings": bindings})
})
.collect();
Ok(json!({"variables": variables}))
Ok(json!({"variables": variables, "locked": st.doc.variables.locked}))
}
/// Lock Variables: without `locked` it toggles. Setting the state the document already has
/// does not edit the document or add an undo step.
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
let was = s.doc()?.doc.variables.locked;
let locked = bool_or(p, "locked", !was);
if locked != was {
s.edit(if locked { "Lock Variables" } else { "Unlock Variables" }, |d, _| {
d.variables.locked = locked;
Ok(())
})?;
}
Ok(json!({"locked": locked}))
}
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
@@ -369,8 +397,8 @@ fn dataset_capture(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Update Data Set: write what the bound objects hold now into the row that is active, for each
/// variable the row holds. A variable whose objects report no value (locked, inside a hidden or
/// locked layer or group, or gone) keeps the row's value. With `all`, every other variable the
/// variable the row holds. A variable whose objects report no value (locked, inside a locked
/// layer or group, or gone) keeps the row's value. With `all`, every other variable the
/// objects report a value for is added to the row. Without an active row there is nothing to
/// update, so say so rather than picking one.
fn dataset_update(s: &mut Session, p: &Value) -> Result<Value> {
@@ -439,28 +467,6 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
for v in &d.variables.variables {
pairs.extend(d.variables.objects_of(&v.name).into_iter().map(|id| (id, v.name.clone())));
}
// The row's values go in three passes: the visibility values that show an object,
// outermost object first; the other values, in definition order; the visibility values
// that hide an object, innermost object first. An object inside a group is then rewritten
// after the row shows the group and before the row hides it. An object with several
// visibility variables goes in the pass of the last one's value, and each pass keeps
// definition order otherwise.
let mut shows = std::collections::HashMap::new();
for (id, var) in &pairs {
let kind = d.variables.variable(var).map(|v| v.kind);
if let (Some(VariableKind::Visibility), Some(DataValue::Visible(show))) = (kind, ds.values.get(var)) {
shows.insert(*id, *show);
}
}
let depths = d.index_paths(shows.keys().copied());
pairs.sort_by_cached_key(|(id, var)| {
let depth = depths.get(id).map_or(0, Vec::len);
match (d.variables.variable(var).map(|v| v.kind), shows.get(id)) {
(Some(VariableKind::Visibility), Some(true)) => (0, depth),
(Some(VariableKind::Visibility), Some(false)) => (2, usize::MAX - depth),
_ => (1, 0),
}
});
let mut applied = 0usize;
let mut skipped = 0usize;
// Collects the type objects whose text the row rewrote, for the reply's `overflow`.
@@ -476,10 +482,10 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
continue;
}
};
// Locked art, art on a locked or hidden layer, and text whose object is gone, can't
// be rewritten: skipped and counted, like the rest. A hidden object is rewritten: a
// row that hid it shows it again.
if !d.variable_target(id) {
// Locked art, art in a locked layer or group, and text whose object is gone, can't be
// rewritten: skipped and counted, like the rest. Hidden art is rewritten, in a hidden
// layer or group too, and stays hidden unless a visibility value shows it.
if !d.dataset_target(id) {
skipped += 1;
continue;
}
@@ -706,10 +712,52 @@ fn datasets_to_csv(vars: &Variables) -> String {
out
}
/// The variable that a datasets CSV header cell (trimmed, not empty) in 1-based `column` names,
/// and its kind. A cell that is the name of a variable in `kinds` or in `defined` (the variables
/// that earlier columns define) names that variable. Otherwise `#` before the name makes it a
/// visibility variable, `@` makes it an image variable, and a cell without a prefix names a text
/// variable. A prefixed name that is already a variable must be one of the prefix's kind; a new
/// name is added to `defined`. A `%` cell that names no variable (graph data files) is refused.
fn header_variable(
cell: &str,
column: usize,
kinds: &BTreeMap<String, VariableKind>,
defined: &mut Vec<Variable>,
cmd: &str,
) -> Result<(String, VariableKind)> {
let known = |name: &str, defined: &[Variable]| kinds.get(name).copied().or_else(|| defined.iter().find(|v| v.name == name).map(|v| v.kind));
if let Some(kind) = known(cell, defined) {
return Ok((cell.to_string(), kind));
}
let (name, kind) = if let Some(rest) = cell.strip_prefix('#') {
(rest.trim(), VariableKind::Visibility)
} else if let Some(rest) = cell.strip_prefix('@') {
(rest.trim(), VariableKind::Image)
} else if cell.starts_with('%') {
return Err(bad(cmd, format!("column {column}: `{cell}` is a `%` column of graph data files, which are not read")));
} else {
(cell, VariableKind::Text)
};
if name.is_empty() {
return Err(bad(cmd, format!("column {column}: `{cell}` names no variable")));
}
match known(name, defined) {
Some(k) if k == kind => Ok((name.to_string(), kind)),
Some(k) => Err(bad(cmd, format!("column {column}: `{cell}` names `{name}`, which is a {} variable", k.label()))),
None if name.chars().count() > MAX_NAME => Err(bad(cmd, format!("column {column}: the variable name is longer than {MAX_NAME} characters"))),
None => {
defined.push(Variable { name: name.to_string(), kind });
Ok((name.to_string(), kind))
}
}
}
/// Import datasets from CSV. A first header cell `name` makes the first column the dataset
/// names; the other headers are variable names. Without it, every header is a variable name
/// and the rows take the first free names "Data Set 1", "Data Set 2" and so on. Existing rows
/// of the same name are replaced; new names are appended. One undo step.
/// names; the other headers name variables, as [`header_variable`] reads them, and the
/// variables the document doesn't define yet are defined, unbound. Without the `name` column,
/// every header names a variable and the rows take the first free names "Data Set 1", "Data
/// Set 2" and so on. Existing rows of the same name are replaced; new names are appended. One
/// undo step.
fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "dataset.import";
let bytes = datasets_csv_bytes(p, C)?;
@@ -726,13 +774,18 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
(kinds, existing)
};
let mut columns: Vec<(usize, String, VariableKind)> = Vec::new();
let mut defined: Vec<Variable> = Vec::new();
for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) {
let name = h.trim();
if name.is_empty() {
let cell = h.trim();
if cell.is_empty() {
return Err(bad(C, format!("empty header in column {}", i + 1)));
}
let kind = kinds.get(name).copied().ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
columns.push((i, name.to_string(), kind));
let (name, kind) = header_variable(cell, i + 1, &kinds, &mut defined, C)?;
columns.push((i, name, kind));
}
// Lock Variables refuses New Variable, so the import is refused when a header would define one.
if !defined.is_empty() {
variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?;
}
if rows.len() == 1 {
return Err(bad(C, "CSV has a header but no data rows"));
@@ -773,7 +826,9 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
parsed.push(DataSet { name, values });
}
let names: Vec<String> = parsed.iter().map(|d| d.name.clone()).collect();
let new_variables: Vec<String> = defined.iter().map(|v| v.name.clone()).collect();
let (imported, updated) = s.edit("Import Data Sets", |d, _| {
d.variables.variables.extend(defined);
let mut imported = 0usize;
let mut updated = 0usize;
for row in parsed {
@@ -787,7 +842,7 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((imported, updated))
})?;
Ok(json!({"imported": imported, "updated": updated, "names": names}))
Ok(json!({"imported": imported, "updated": updated, "names": names, "defined": new_variables}))
}
/// Export every dataset as CSV (`name` then variables). With `path`, write the file; else return `{csv}`.
@@ -835,8 +890,8 @@ pub fn specs() -> Vec<CommandSpec> {
"New Variable…",
["Window", "Variables"],
None,
"{name, kind: text|visibility|image|graph} define a variable (graph: CSV data on a graph root)",
has_doc,
"{name, kind: text|visibility|image|graph} define a variable (graph: CSV data on a graph root); refused while the variables are locked",
variables_unlocked,
define
),
cmd!(
@@ -844,8 +899,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Variable",
["Window", "Variables"],
None,
"{names?} delete variables with their bindings and values (default: the highlighted one)",
has_doc,
"{names?} delete variables with their bindings and values (default: the highlighted one); refused while the variables are locked",
variables_unlocked,
delete
),
cmd!(
@@ -853,18 +908,18 @@ pub fn specs() -> Vec<CommandSpec> {
"Rename Variable",
["Window", "Variables"],
None,
"{name, newName} rename a variable, with its bindings and its values in every dataset",
has_doc,
"{name, newName} rename a variable, with its bindings and its values in every dataset; refused while the variables are locked",
variables_unlocked,
rename
),
cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts", has_doc, list),
cmd!(query "variable.list", "List Variables", [], None, "{} → variables with kinds and binding counts, and whether they are locked → {variables, locked}", has_doc, list),
cmd!(
"variable.makeTextDynamic",
"Make Text Dynamic",
["Window", "Variables"],
None,
"{} define a text variable over the selection, named after it and bound to it",
has_selection,
"{} define a text variable over the selection, named after it and bound to it; refused while the variables are locked",
selection_and_unlocked,
|s, _| make_dynamic(s, VariableKind::Text, "Text")
),
cmd!(
@@ -872,8 +927,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Make Visibility Dynamic",
["Window", "Variables"],
None,
"{} define a visibility variable over the selection, named after it and bound to it",
has_selection,
"{} define a visibility variable over the selection, named after it and bound to it; refused while the variables are locked",
selection_and_unlocked,
|s, _| make_dynamic(s, VariableKind::Visibility, "Visibility")
),
cmd!(
@@ -881,8 +936,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Make Image Dynamic",
["Window", "Variables"],
None,
"{} define an image variable over the selection, named after it and bound to it",
has_selection,
"{} define an image variable over the selection, named after it and bound to it; refused while the variables are locked",
selection_and_unlocked,
|s, _| make_dynamic(s, VariableKind::Image, "Image")
),
cmd!(
@@ -890,8 +945,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Make Graph Dynamic",
["Window", "Variables"],
None,
"{} define a graph variable over the selected graph root(s), named after it and bound to it; each dataset row supplies CSV as graph.setData",
has_selection,
"{} define a graph variable over the selected graph root(s), named after it and bound to it; each dataset row supplies CSV as graph.setData; refused while the variables are locked",
selection_and_unlocked,
|s, _| make_dynamic(s, VariableKind::Graph, "Graph")
),
cmd!(
@@ -912,6 +967,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
unbind
),
cmd!(
"variable.lock",
"Lock Variables",
["Window", "Variables"],
None,
"{locked?: bool} lock or unlock the variables (default: toggle); saved with the document; a change is one undo step. While locked, variable.define, variable.delete, variable.rename and variable.make…Dynamic are refused; binding, unbinding and the dataset commands still run → {locked}",
has_doc,
lock
),
cmd!(
"variable.highlight",
"Highlight Variable",
@@ -946,7 +1010,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Update Data Set",
["Window", "Variables"],
None,
"{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (locked, inside a hidden or locked layer or group, or gone) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}",
"{all?: bool} write what the bound objects hold now into the active dataset, for each variable the dataset holds; a variable whose objects report no value (locked, inside a locked layer or group, or gone) keeps the dataset's value. `all: true` also adds every other variable the objects report a value for; `left` names the variables it didn't write → {values, left}",
has_doc,
dataset_update
),
@@ -990,7 +1054,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Data Sets…",
["Window", "Variables"],
None,
"{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). Rows whose name already exists are replaced → {imported, updated, names}",
"{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). A header that names no variable defines one, unbound: `#Name` defines a visibility variable, `@Name` defines an image variable, and a header without a prefix defines a text variable. A prefixed header whose name is a variable of another kind, and a `%` header that isn't a variable's exact name (graph data files), are refused, and so is a header that would define a variable while the variables are locked. Rows whose name already exists are replaced → {imported, updated, names, defined}",
has_doc,
dataset_import
),
+23 -1
View File
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::select::selectable_ids;
use super::*;
/// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in
@@ -153,7 +154,13 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone();
let w = s.menu.wand.clone();
let hits: Vec<NodeId> = paintable(&st.doc).into_iter().filter(|i| st.doc.node(*i).is_some_and(|n| w.matches(&reference, n))).collect();
// In isolation mode only the isolated group's contents can be picked, as for Select Same.
let inside = st.isolation.map(|iso| selectable_ids(&st.doc, Some(iso)));
let hits: Vec<NodeId> = paintable(&st.doc)
.into_iter()
.filter(|i| inside.as_ref().is_none_or(|set| set.contains(i)))
.filter(|i| st.doc.node(*i).is_some_and(|n| w.matches(&reference, n)))
.collect();
let ids: Vec<NodeId> = match str_param(p, "mode").unwrap_or("set") {
"add" => st.selection.objects.iter().copied().chain(hits.iter().copied().filter(|h| !st.selection.objects.contains(h))).collect(),
"subtract" => st.selection.objects.iter().copied().filter(|i| !hits.contains(i)).collect(),
@@ -222,4 +229,19 @@ mod tests {
s.execute("select.magicWand", &json!({"id": b})).unwrap();
assert_eq!(selected(&s), [b]);
}
#[test]
fn isolation_mode_limits_the_wand_to_the_isolated_group() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
let a = rect(&mut s, 0.0, "#ff0000", 1.0);
let b = rect(&mut s, 20.0, "#ff0000", 1.0);
let outside = rect(&mut s, 40.0, "#ff0000", 1.0);
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("object.isolate", &json!({"id": g})).unwrap();
s.execute("select.magicWand", &json!({"id": a})).unwrap();
assert_eq!(selected(&s), [a, b]);
assert!(!selected(&s).contains(&outside));
}
}
+23 -9
View File
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Web",
[],
None,
"{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray (gif/png8, as the next five), colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), quality?: 60 (jpg: 0–100), progressive?: false (jpg), optimized?: true (jpg), embedProfile?: false (jpg: sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/<slice name>.<ext>), output?: images (default)|html (an HTML page placing the image or slices, <stem>.html)} → {path, files: [path…], bytes (total), width, height}; no path → {files: [{name, dataBase64}], bytes, width, height} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself",
"{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray (gif/png8, as the next five), colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), quality?: 60 (jpg: 0–100), progressive?: false (jpg), optimized?: true (jpg), embedProfile?: false (jpg: sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/<slice name>.<ext>), output?: images (default)|html (an HTML page placing the image or slices, <stem>.html)} → {path, files: [path…], bytes (total), width, height, warnings: [] or [a note that an image couldn't be decoded and is missing from the image]}; no path → {files: [{name, dataBase64}], bytes, width, height, warnings} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself",
has_doc,
export
),
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Web Preview",
[],
None,
"{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, dataBase64?}",
"{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, warnings (as document.exportForWeb's), dataBase64?}",
has_doc,
preview
),
@@ -873,9 +873,11 @@ pub fn export_files(doc: &Document, selected: &[NodeId], s: &WebSettings, stem:
continue;
}
let base: String = doc.slice_name(c.area).chars().map(|ch| if ch.is_alphanumeric() || "-_.".contains(ch) { ch } else { '-' }).collect();
let base = unique_name(&base, |n| taken.contains(&n.to_lowercase()));
taken.insert(base.to_lowercase());
let name = format!("{IMAGES}/{base}.{ext}");
// A slice named after a Windows device (`aux`, `NUL`…) gets a `-` after the device name.
let file = |base: &str| super::fileio::safe_file_name(&format!("{base}.{ext}"));
let base = unique_name(&base, |n| taken.contains(&file(n).to_lowercase()));
taken.insert(file(&base).to_lowercase());
let name = format!("{IMAGES}/{}", file(&base));
let [x0, y0, x1, y1] = c.px;
images.push(WebFile { name: name.clone(), bytes: optimize(doc, &crop(&r, c.px), x1 - x0, y1 - y0, s)?.bytes });
placed.push((Some(c), Some(name)));
@@ -967,14 +969,17 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").filter(|p| !p.trim().is_empty());
let stem = match path {
Some(p) => super::fileio::file_stem(p),
None => super::fileio::file_stem(&st.doc.title),
// Without a path the files are named after the title, following Windows' file-naming rules.
None => super::fileio::safe_file_name(&super::fileio::file_stem(&st.doc.title)),
};
let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem };
let failed = vectorcraft_render::decode_failures();
let (files, (width, height)) = export_files(&st.doc, &selected, &settings, &stem).map_err(|e| bad(C, e))?;
let warnings = undecoded(failed);
let total: usize = files.iter().map(|f| f.bytes.len()).sum();
let Some(path) = path else {
let rows: Vec<Value> = files.iter().map(|f| json!({ "name": f.name, "dataBase64": vectorcraft_format::base64_encode(&f.bytes) })).collect();
return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height }));
return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height, "warnings": warnings }));
};
// Siblings keep the path's own separators.
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
@@ -988,7 +993,14 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
super::fileio::write_file(&target, &f.bytes)?;
written.push(target);
}
Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height }))
Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height, "warnings": warnings }))
}
/// The warnings of a rendering that started when this thread's decode failure count was `failed`:
/// the note the raster exports add (`fileio::encode::encode_all_of`) when an image has failed to
/// decode since then and so is missing from the image.
fn undecoded(failed: u64) -> Vec<&'static str> {
super::rasterfx::undecoded_warning(failed).into_iter().collect()
}
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1000,7 +1012,9 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
}
let st = s.doc()?;
let doc = &st.doc;
let failed = vectorcraft_render::decode_failures();
let r = render(doc, &settings).map_err(|e| bad(P, e))?;
let warnings = undecoded(failed);
let areas = doc.slice_layout();
let selected = super::slices::selected_slices(doc, &st.selection);
let cells = cells(&areas, &selected, &settings, &r).map_err(|e| bad(P, e))?;
@@ -1016,7 +1030,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
.map_err(|e| bad(P, e))?;
let mut out = json!({
"format": settings.format.id(), "width": o.width, "height": o.height, "bytes": o.bytes.len(),
"seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images,
"seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images, "warnings": warnings,
});
if settings.format.palette() {
out["colors"] = o
+2 -2
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Sample Color",
[],
None,
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient)",
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it",
has_doc,
sample_color
),
@@ -346,7 +346,7 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })),
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(C, "`stop` must be a stop index"))? as usize;
let color = color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?;
let color = super::paint::new_color(s, p, color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?);
let (fill, stroke_paint) = s.proxy_paints();
let Paint::Gradient(g) = (if stroke { stroke_paint } else { fill }) else {
return Err(bad(C, "the paint behind the proxy is not a gradient"));
+93 -8
View File
@@ -287,6 +287,8 @@ pub struct DocState {
/// The document as last saved (or opened). Structural sharing makes "unchanged" a pointer
/// comparison, and undoing back to the saved state counts as clean.
saved_doc: Arc<Document>,
/// The documents that changes that are not edits to save replaced ([`DocState::same_content`]).
unsaved_changes: SameContent,
/// The layer new art goes into (the "current layer" in the Layers panel).
pub active_layer: Option<NodeId>,
/// The rows highlighted in the Layers panel (layers, sublayers, groups or objects, in the order
@@ -452,6 +454,36 @@ impl RecalledSelection {
}
}
/// The documents that changes that are not edits to save (Puppet Warp pins dropped, Edit Text ended,
/// type laid out again after a font scan) replaced, each counted as having the content of the
/// document it was replaced by ([`DocState::change_unsaved`]). A document that nothing holds any
/// more is dropped the next time one is added.
#[derive(Clone, Debug, Default)]
struct SameContent {
/// The document the last such change made.
now: std::sync::Weak<Document>,
/// The documents with its content that such changes replaced, oldest first.
before: Vec<std::sync::Weak<Document>>,
}
impl SameContent {
/// Note that `now` replaced `before` without a change to its content.
fn replaced(&mut self, before: std::sync::Weak<Document>, now: &Arc<Document>) {
if !self.now.ptr_eq(&before) {
self.before.clear();
}
self.before.retain(|d| d.strong_count() > 0);
if before.strong_count() > 0 {
self.before.push(before);
}
self.now = Arc::downgrade(now);
}
/// Whether `doc` is one of the documents with the content of `now`.
fn holds(&self, now: &Arc<Document>, doc: &Arc<Document>) -> bool {
std::ptr::eq(self.now.as_ptr(), Arc::as_ptr(now)) && self.before.iter().any(|d| std::ptr::eq(d.as_ptr(), Arc::as_ptr(doc)))
}
}
impl DocState {
pub fn new(mut doc: Document, path: Option<String>) -> Self {
let active_layer = doc.default_layer();
@@ -463,6 +495,7 @@ impl DocState {
let doc = Arc::new(doc);
Self {
saved_doc: doc.clone(),
unsaved_changes: SameContent::default(),
doc,
selection: Selection::default(),
history: History { limit: 500, ..Default::default() },
@@ -493,9 +526,10 @@ impl DocState {
last_perspective: None,
}
}
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
/// Unsaved changes: the document differs from the saved one (selection changes and changes that
/// are not edits to save don't count, [`DocState::same_content`]).
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(&self.doc, &self.saved_doc)
!self.same_content(&self.saved_doc)
}
/// Record the current document as saved.
pub fn mark_saved(&mut self) {
@@ -506,6 +540,26 @@ impl DocState {
pub fn mark_saved_as(&mut self, snapshot: &Arc<Document>) {
self.saved_doc = snapshot.clone();
}
/// Whether `doc` counts as having the content of the document now: it is the document, or one
/// that changes that are not edits to save replaced since ([`DocState::change_unsaved`]).
pub fn same_content(&self, doc: &Arc<Document>) -> bool {
Arc::ptr_eq(&self.doc, doc) || self.unsaved_changes.holds(&self.doc, doc)
}
/// Make a change that is not an edit to save with `f`, without an undo step: editing state that
/// is never saved, or type laid out again after a font scan (which can also refit the frame of
/// Auto Size area type). A saved document stays saved, and a save or recovery copy taken of the
/// document before counts as having its content ([`DocState::same_content`]).
pub(crate) fn change_unsaved(&mut self, f: impl FnOnce(&mut Arc<Document>)) {
let saved = !self.is_dirty();
let before = Arc::downgrade(&self.doc);
f(&mut self.doc);
if !std::ptr::eq(before.as_ptr(), Arc::as_ptr(&self.doc)) {
self.unsaved_changes.replaced(before, &self.doc);
}
if saved {
self.mark_saved();
}
}
/// Keep what interaction `it` (taken from this document) changed, as one undo step.
pub(crate) fn keep_interaction(&mut self, it: Interaction) {
if !Arc::ptr_eq(&it.doc, &self.doc) {
@@ -682,6 +736,18 @@ pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool {
d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: true, .. }))
}
/// Deselect the ruler guides and the paths made into guides: while View → Guides → Lock Guides is
/// on, they can't be selected (#1534), whatever selects them. → whether any was selected.
pub(crate) fn deselect_guides(d: &Document, sel: &mut Selection) -> bool {
let paths: Vec<NodeId> = sel.objects.iter().copied().filter(|id| is_guide_path(d, *id)).collect();
let any = !paths.is_empty() || !sel.guides.is_empty();
for id in paths {
sel.remove(id);
}
sel.guides.clear();
any
}
/// The error of an edit that would nest objects deeper than [`vectorcraft_doc::MAX_DEPTH`].
pub const TOO_DEEP: &str = "objects can't be nested more than 32 deep";
const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catalog rows) names the cap");
@@ -737,6 +803,8 @@ pub struct Prefs {
pub zoom_with_mouse_wheel: bool,
/// A horizontal drag on a numeric field or its label steps its value (#400).
pub scrub_numeric_fields: bool,
/// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°.
pub trackpad_rotate_view: bool,
/// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this).
pub paste_offset: f64,
// Selection & Anchor Display
@@ -850,7 +918,8 @@ pub struct Prefs {
/// Rasterizer worker threads; -1 = automatic.
pub render_threads: i32,
/// Linux: `automatic` opens the window on the session's display server; `x11` opens it through
/// XWayland on a Wayland session, where dropped files arrive (#1151). Read when the app starts.
/// XWayland on a Wayland session, where dropped files arrive (#1151); `x11WithPen` opens it
/// through XWayland only when a pen is attached (#2028). Read when the app starts.
pub linux_display_server: String,
// File Handling
pub background_save: bool,
@@ -992,6 +1061,7 @@ impl Default for Prefs {
scale_strokes: false,
zoom_with_mouse_wheel: false,
scrub_numeric_fields: true,
trackpad_rotate_view: true,
paste_offset: 10.0,
selection_tolerance: 3.0,
object_selection_by_path_only: false,
@@ -1183,6 +1253,9 @@ pub struct Session {
/// Where the ICC profiles `color.loadProfile` loads are kept; `None`: only for the process
/// ([`Session::attach_profile_store`]).
profile_dir: Option<std::path::PathBuf>,
/// Where the plug-ins `plugin.install` installs are kept; `None`: only for the process
/// ([`Session::attach_plugin_store`]).
plugin_dir: Option<std::path::PathBuf>,
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
pub recent_urls: Vec<String>,
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
@@ -1277,6 +1350,7 @@ impl Session {
libraries: Default::default(),
pdf_preset_dir: None,
profile_dir: None,
plugin_dir: None,
recent_urls: vec![],
ui_language: None,
journal_note: Default::default(),
@@ -1595,6 +1669,7 @@ impl Session {
if !self.previewing && cmd::typing_in_progress(self) {
cmd::settle_interaction(self)?;
}
let guides_locked = self.menu.guides_locked;
let st = self.doc_mut()?;
let before = st.doc.clone();
let before_sel = st.selection.clone();
@@ -1642,6 +1717,10 @@ impl Session {
match result {
Ok(v) => {
st.selection.prune(&st.doc);
// An edit that selects what it unlocks, shows or pastes leaves locked guides out.
if guides_locked {
deselect_guides(&st.doc, &mut st.selection);
}
st.revision += 1;
if st.interaction.is_none() {
let selection = match st.history.undo.last() {
@@ -1670,10 +1749,7 @@ impl Session {
st.selection.prune(&st.doc);
// Lock Guides locks the paths made into guides too (#1534), whatever selects them.
if guides_locked {
let locked: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| is_guide_path(&st.doc, *id)).collect();
for id in locked {
st.selection.remove(id);
}
deselect_guides(&st.doc, &mut st.selection);
}
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
// the reference app: new art then goes beside it.
@@ -1804,7 +1880,13 @@ impl Session {
pub fn cancel_interaction(&mut self) -> Result<()> {
self.pending_paint = None;
self.doc_mut()?.undo_interaction();
let guides_locked = self.menu.guides_locked;
let st = self.doc_mut()?;
st.undo_interaction();
// Guides locked during the interaction stay deselected.
if guides_locked {
deselect_guides(&st.doc, &mut st.selection);
}
Ok(())
}
@@ -1846,6 +1928,9 @@ impl Session {
{
st.doc = e.doc;
st.selection = Arc::unwrap_or_clone(e.selection);
if self.menu.guides_locked {
deselect_guides(&st.doc, &mut st.selection);
}
st.revision += 1;
journal = Some(g.journal);
}
+83 -12
View File
@@ -1,6 +1,7 @@
//! The user's own PDF presets and ICC profiles, kept in folders next to the preferences so that
//! they outlive the process (#2168, #2174). The apps decide where ([`Session::attach_pdf_preset_store`],
//! [`Session::attach_profile_store`]); a session nobody attached a folder to keeps nothing, which
//! The user's own PDF presets, ICC profiles and plug-ins, kept in folders next to the preferences
//! so that they outlive the process (#2168, #2174, #2541). The apps decide where
//! ([`Session::attach_pdf_preset_store`], [`Session::attach_profile_store`],
//! [`Session::attach_plugin_store`]); a session nobody attached a folder to keeps nothing, which
//! is how the engine's own tests and embedders run.
//!
//! * **PDF presets**: one `.vcpdfpresets` file per preset (the format `pdf.preset.export` writes,
@@ -10,6 +11,10 @@
//! * **ICC profiles**: the bytes of each profile `color.loadProfile` registered, as `.icc` files
//! named after the profile, never the path they came from (a profile in a system folder may
//! move or go away). Attaching the folder registers them again.
//! * **Plug-ins**: the module of each plug-in `plugin.install` installs, as a `.wasm` file named
//! after its id (as Illustrator keeps plug-ins in its Plug-ins folder); `plugin.remove` deletes it.
//! Attaching the folder installs them again. The Additional Plug-ins Folder preference is not
//! copied: it is loaded from where it is.
//!
//! The files are untrusted input like any other: sizes and counts are capped, a file that does not
//! parse is skipped with a log record, and every write is atomic.
@@ -32,6 +37,10 @@ pub const MAX_PROFILE_FILE: u64 = 32 << 20;
pub const MAX_PROFILE_STORE: u64 = 256 << 20;
/// The extension of a stored ICC profile.
const PROFILE_EXT: &str = "icc";
/// The most stored plug-in bytes read at startup, all files together (the rest are skipped).
pub const MAX_PLUGIN_STORE: u64 = 512 << 20;
/// The extension of a stored plug-in.
const PLUGIN_EXT: &str = "wasm";
/// The file name (without extension) a stored item called `name` gets: its name made safe for any
/// file system, then a hash of the name, so that names that differ only in characters a file name
@@ -124,22 +133,46 @@ impl Session {
}
/// Write the saved preset `preset` to the store, which held it as `old_name` when that is set
/// (a rename). Without a store, nothing happens.
/// (a rename). A rename writes the new file first and removes the old one only when that write
/// succeeded: after a failed write the store still holds the preset under its old name. When
/// the two names differ only in case, the old file is written and then renamed to the new name;
/// when that rename fails, the old file gets its content back. Without a store, nothing happens.
pub(crate) fn store_pdf_preset(&self, old_name: Option<&str>, preset: &PdfPreset) {
let Some(dir) = &self.pdf_preset_dir else { return };
let mut clean = preset.clone();
clean.settings.clear_passwords();
let file = format!("{}.{PRESET_FORMAT}", file_stem(&clean.name));
if let Some(old) = old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o)))
&& old != file
{
take(dir, &old);
}
match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) {
Ok(bytes) => {
let bytes = match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) {
Ok(bytes) => bytes,
Err(e) => {
log::warn!("can't save the PDF preset `{}`: {e}", preset.name);
return;
}
};
match old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o))).filter(|old| *old != file) {
// Names equal in any case get file names that differ only in case. A file system that
// ignores case holds them as one file, which removing the old name would delete.
Some(old) if old.to_lowercase() == file.to_lowercase() => match read_capped(&dir.join(&old), MAX_PRESET_FILE) {
Ok(before) => {
if put(dir, &old, &bytes)
&& let Err(e) = std::fs::rename(dir.join(&old), dir.join(&file))
{
log::warn!("can't rename {} to {file}: {e}", dir.join(&old).display());
put(dir, &old, &before);
}
}
Err(_) => {
put(dir, &file, &bytes);
}
},
Some(old) => {
if put(dir, &file, &bytes) {
take(dir, &old);
}
}
None => {
put(dir, &file, &bytes);
}
Err(e) => log::warn!("can't save the PDF preset `{}`: {e}", preset.name),
}
}
@@ -181,6 +214,44 @@ impl Session {
take(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)));
}
}
/// Keep the plug-ins this session installs in `dir`, and install the ones already there (a
/// module that doesn't load is skipped with a log record).
pub fn attach_plugin_store(&mut self, dir: PathBuf) {
let limits = vectorcraft_plugins::Limits::default();
let mut left = MAX_PLUGIN_STORE;
for path in list_files(&dir, PLUGIN_EXT) {
if left == 0 {
log::warn!("skipping the other stored plug-ins: they hold more than {MAX_PLUGIN_STORE} bytes together");
break;
}
let loaded = read_capped(&path, (limits.max_module_bytes as u64).min(left)).and_then(|bytes| {
left -= bytes.len() as u64;
vectorcraft_plugins::Plugin::load(&bytes, limits.clone()).map_err(|e| e.to_string())
});
match loaded {
Ok(plugin) => {
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
}
Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()),
}
}
self.plugin_dir = Some(dir);
}
/// Write the module `bytes` of the plug-in `id` to the store.
pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) {
if let Some(dir) = &self.plugin_dir {
put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes);
}
}
/// Remove the plug-in `id` from the store.
pub(crate) fn unstore_plugin(&self, id: &str) {
if let Some(dir) = &self.plugin_dir {
take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)));
}
}
}
/// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a
+52 -4
View File
@@ -1708,6 +1708,27 @@ fn direct_selection_snaps_anchors_to_the_grid() {
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(201.0, 101.0));
}
/// #2054: with Snap to Grid on, a bounding-box handle dragged with the Selection tool or the Free
/// Transform tool lands the corner it moves on the grid, even with Snap to Pixel left on too; the
/// opposite corner stays put.
#[test]
fn bounding_box_handles_snap_to_the_grid() {
for tool in ["selection", "freeTransform"] {
let mut s = session();
let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0);
let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1));
let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;
let v = ViewInfo { snap_to_grid: true, snap_to_pixel: true, ..ViewInfo::default() };
s.select_tool(tool, v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 201.0, 201.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 230.0, 214.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 230.0, 214.0), v).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert!(on_grid(b.x1) && on_grid(b.y1), "{tool}: {b:?}, grid step {step}");
assert!((b.x0 - 101.0).abs() < 1e-9 && (b.y0 - 101.0).abs() < 1e-9, "{tool}: {b:?}");
}
}
/// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all
/// its anchors, so toggling one deselects it alone and adding one keeps them all.
#[test]
@@ -1952,13 +1973,14 @@ fn isolated_ring(s: &mut Session) -> (NodeId, NodeId, NodeId) {
(c, outer, hole)
}
/// Isolating a compound path makes its paths objects of their own: the hole of a ring, selected
/// alone, moves without the ring around it. Outside isolation it still moves its compound path.
/// A path of a compound path selected alone is an object of its own for the transforms: the hole
/// of a ring moves without the ring around it, in the isolated compound path and outside it, where
/// Group Selection selects it (#2441). Selecting the compound path moves the whole ring.
#[test]
fn an_isolated_compound_paths_member_moves_alone() {
let x0 = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
let mut s = session();
let (_, outer, hole) = isolated_ring(&mut s);
let (c, outer, hole) = isolated_ring(&mut s);
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 0]})).unwrap();
assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 40.0), "only the hole moved");
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
@@ -1966,7 +1988,10 @@ fn an_isolated_compound_paths_member_moves_alone() {
s.execute("object.exitIsolation", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [hole.0]})).unwrap();
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap();
assert_eq!((x0(&s, outer), x0(&s, hole)), (5.0, 50.0), "not isolated: the whole compound path moved");
assert_eq!((x0(&s, outer), x0(&s, hole)), (0.0, 50.0), "not isolated: still only the hole");
s.execute("select.set", &json!({"ids": [c.0]})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
assert_eq!((x0(&s, outer), x0(&s, hole)), (5.0, 55.0), "the compound path selected: the whole ring");
}
/// `depth` and `childLimit` slice a summary read; a level that shows fewer children
@@ -2308,6 +2333,29 @@ fn object_mosaic_tiles_follow_the_image_colours() {
assert!(!st.doc.layers[0].children().unwrap().iter().any(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))));
}
/// Columns and rows that are not whole numbers (a fraction, a negative number, a string or another value) are an
/// error naming the key, and no mosaic is made; 2.0 columns are two columns.
#[test]
fn object_mosaic_refuses_columns_and_rows_that_are_not_whole_numbers() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 100.0, 50.0);
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
let before = undo(&s);
for (p, msg) in [
(json!({"columns": 2.5}), "`columns` must be a whole number"),
(json!({"rows": -1}), "`rows` must be a whole number"),
(json!({"columns": "4"}), "`columns` must be a number, not \"4\""),
(json!({"rows": true}), "`rows` must be a number, not true"),
] {
let r = s.execute("object.createObjectMosaic", &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}");
assert_eq!(undo(&s), before, "{p}");
}
let r = s.execute("object.createObjectMosaic", &json!({"columns": 2.0, "rows": 1.0})).unwrap();
assert_eq!(r["tiles"], json!(2));
}
#[test]
fn snap_to_pixel_rounds_drawing_and_moves() {
let mut s = session();
+68
View File
@@ -483,3 +483,71 @@ fn making_and_restyling_type_reports_its_overflow() {
let inside = s.execute("text.createInPath", &json!({"path": frame["id"], "mode": "area", "text": STORY, "size": 12, "fit": "none"})).unwrap();
assert_eq!(inside["overflow"], json!([inside["id"]]), "{inside}");
}
/// The formats whose exports draw type.
const DRAWING_FORMATS: [&str; 8] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf"];
/// The warnings of `document.export {format, …options}` about type that doesn't fit its frame.
fn export_overflow(s: &mut Session, format: &str, options: Value) -> Vec<String> {
let mut p = json!({ "format": format });
if let (Some(p), Some(o)) = (p.as_object_mut(), options.as_object()) {
p.extend(o.clone());
}
let v = s.execute("document.export", &p).unwrap();
let over = |w: &&str| w.contains("fit its frame") || w.contains("fit their frames");
v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(over).map(str::to_string).collect()
}
#[test]
fn exports_that_draw_type_name_the_type_that_does_not_fit() {
let mut s = session();
let id = area(&mut s, 100.0, 20.0, "Ab\nCd\nEf\nGh", json!({}));
assert!(overflows(&s, id));
let one = format!("1 text object doesn't fit its frame: the text past the end of the frame isn't drawn in the export (id {})", id.0);
for format in DRAWING_FORMATS {
assert_eq!(export_overflow(&mut s, format, json!({})), [one.as_str()], "{format}");
}
// A .ai file carries the whole text in its editing data.
assert_eq!(export_overflow(&mut s, "ai", json!({})), Vec::<String>::new());
// A frame that holds its text, or a hidden object, leaves nothing out.
s.execute("text.areaOptions", &json!({"ids": [id.0], "height": 150})).unwrap();
for format in DRAWING_FORMATS {
assert_eq!(export_overflow(&mut s, format, json!({})), Vec::<String>::new(), "{format}");
}
s.execute("text.areaOptions", &json!({"ids": [id.0], "height": 20})).unwrap();
s.execute("object.setProps", &json!({"ids": [id.0], "visible": false})).unwrap();
// The type is hidden, so a rectangle gives the EPS of the visible art something to export.
s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 50, "height": 50})).unwrap();
for format in DRAWING_FORMATS {
assert_eq!(export_overflow(&mut s, format, json!({})), Vec::<String>::new(), "{format}");
}
}
#[test]
fn exports_name_only_the_type_on_the_artboards_and_layers_they_draw() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap();
let r = s.doc().unwrap().doc.artboards[1].rect;
let at = json!({"x": r.x0 + 10.0, "y": r.y0 + 10.0, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}});
let v = s.execute("text.create", &at).unwrap();
assert_eq!(v["overflow"], json!([v["id"]]), "{v}");
for (format, first, second) in [
("pdf", json!({"range": "1"}), json!({"range": "2"})),
("eps", json!({"useArtboards": true, "range": "1"}), json!({"useArtboards": true, "range": "2"})),
("svg", json!({"range": "1"}), json!({"range": "2"})),
("png", json!({"artboard": 0}), json!({"artboard": 1})),
("emf", json!({"artboard": 0}), json!({"artboard": 1})),
] {
assert_eq!(export_overflow(&mut s, format, first), Vec::<String>::new(), "{format}");
assert_eq!(export_overflow(&mut s, format, second).len(), 1, "{format}");
}
// An EPS of the visible art and an SVG of the whole document hold it too.
assert_eq!(export_overflow(&mut s, "eps", json!({})).len(), 1);
assert_eq!(export_overflow(&mut s, "svg", json!({})).len(), 1);
// On a layer that doesn't print: a PDF leaves it out unless asked to keep it; a PNG draws it.
let layer = s.doc().unwrap().doc.layers[0].id;
s.execute("layer.setProps", &json!({"id": layer.0, "printable": false})).unwrap();
assert_eq!(export_overflow(&mut s, "pdf", json!({"range": "2"})), Vec::<String>::new());
assert_eq!(export_overflow(&mut s, "pdf", json!({"range": "2", "includeNonPrinting": true})).len(), 1);
assert_eq!(export_overflow(&mut s, "png", json!({"artboard": 1})).len(), 1);
}
+71
View File
@@ -66,6 +66,55 @@ fn a_spine_point_added_and_moved_bends_the_blend() {
assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 5.0)) < 1e-6);
}
/// #2544: an Anchor Point drag on a blend spine must create two symmetric
/// handles, not bend the adjacent segment and leave one handle behind.
#[test]
fn spine_anchor_conversion_preserves_keys_and_undo() {
let mut s = session();
let (a, b, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
let keys_before = spec(&s, g).key_anchors;
let point_before = spec(&s, g).spine.unwrap().subpaths[0].anchors[1].p;
assert!(point_before.distance(Point::new(55.0, 5.0)) < 1e-9);
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "smooth", "x": 55, "y": 35})).unwrap();
let sp = spec(&s, g);
assert_eq!(sp.key_anchors, keys_before);
let middle = sp.spine.as_ref().unwrap().subpaths[0].anchors[1];
assert_eq!(middle.kind, vectorcraft_geom::AnchorKind::Smooth);
assert_eq!(middle.p, point_before);
assert_eq!(middle.h_in, point_before - (Point::new(55.0, 35.0) - point_before));
assert_eq!(middle.h_out, Point::new(55.0, 35.0));
assert_eq!(center(&node(&s, a)), Point::new(5.0, 5.0));
assert_eq!(center(&node(&s, b)), Point::new(105.0, 5.0));
s.execute("edit.undo", &json!({})).unwrap();
let before = spec(&s, g);
assert_eq!(before.spine.unwrap().subpaths[0].anchors[1].h_out, point_before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1], middle);
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "corner"})).unwrap();
let corner = spec(&s, g).spine.unwrap().subpaths[0].anchors[1];
assert!(!corner.has_in() && !corner.has_out());
assert_eq!(corner.kind, vectorcraft_geom::AnchorKind::Corner);
}
#[test]
fn spine_conversion_rejects_invalid_arguments_without_mutation() {
let mut s = session();
let (_, _, g) = blend(&mut s);
let before = spec(&s, g);
for params in [
json!({"id": g.0, "anchor": 5, "to": "smooth", "x": 20, "y": 20}),
json!({"id": g.0, "anchor": 0, "to": "unknown"}),
json!({"id": g.0, "anchor": 0, "to": "smooth", "x": 10}),
json!({"id": g.0, "anchor": 0, "to": "smooth", "x": f64::MAX, "y": 1}),
] {
assert!(s.execute("object.blend.spine.convertAnchor", &params).is_err(), "{params}");
assert_eq!(spec(&s, g), before);
}
}
#[test]
fn moving_a_key_point_moves_the_key_and_the_other_way_round() {
let mut s = session();
@@ -99,6 +148,28 @@ fn only_points_without_a_key_can_be_deleted() {
assert_eq!((sp.spine.unwrap().subpaths[0].anchors.len(), sp.key_anchors), (2, vec![0, 1]));
}
/// An `anchor` or `segment` that is not a whole number is an error that leaves the blend as it
/// was; a whole number written as 1.0 still names its point.
#[test]
fn spine_commands_refuse_an_index_that_is_not_a_whole_number() {
let mut s = session();
let (_, _, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
let before = node(&s, g);
for (cmd, key, p) in [
("object.blend.spine.moveAnchor", "anchor", json!({"id": g.0, "anchor": 1.5, "x": 55, "y": 60})),
("object.blend.spine.moveAnchor", "anchor", json!({"id": g.0, "anchor": 0.5, "handle": "out", "x": 5, "y": 300})),
("object.blend.spine.reshapeSegment", "segment", json!({"id": g.0, "segment": 0.5, "dy": 40})),
("object.blend.spine.removeAnchor", "anchor", json!({"id": g.0, "anchor": 1.5})),
] {
let r = s.execute(cmd, &p);
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a whole number")), "{cmd} {p}: {r:?}");
assert_eq!(node(&s, g), before, "{cmd} {p}");
}
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1.0, "x": 55, "y": 60})).unwrap();
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1].p, Point::new(55.0, 60.0));
}
#[test]
fn info_lists_the_spine() {
let mut s = session();
+66
View File
@@ -140,6 +140,32 @@ fn brush_options_rename_duplicate_and_delete_update_strokes() {
assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err());
}
/// New Brush, Duplicate Brush and Brush Options trim the name they are given, as the Brush Options
/// dialog does; the trimmed name is the one compared with the brushes there are, and a blank name
/// gives the name the command gives when there is none.
#[test]
fn new_duplicate_and_renamed_brushes_trim_their_names() {
let mut s = session();
let new = |s: &mut Session, name: &str| s.execute("brush.new", &json!({"type": "calligraphic", "name": name})).unwrap()["name"].clone();
assert_eq!(new(&mut s, " Nib \t"), "Nib");
assert_eq!(new(&mut s, " Nib"), "Nib 2");
assert_eq!(new(&mut s, " \t "), "New Calligraphic Brush");
assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " Quill\t"})).unwrap()["name"], "Quill");
assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " "})).unwrap()["name"], "Nib copy");
assert_eq!(s.execute("brush.options", &json!({"name": "Nib 2", "newName": " Pen "})).unwrap()["name"], "Pen");
assert_eq!(s.execute("brush.options", &json!({"name": "Pen", "newName": " "})).unwrap()["name"], "Pen");
let e = s.execute("brush.options", &json!({"name": "Pen", "newName": " Quill"})).unwrap_err().to_string();
assert!(e.contains("a brush named `Quill` exists"), "{e}");
// Duplicate Brush names the copy of a brush whose saved name has white space around it, as a
// saved file can hold, from the trimmed name (" Old " gives "Old copy").
let mut lib = vectorcraft_brush::library(&s.doc().unwrap().doc);
lib.iter_mut().filter(|b| b.name == "Pen").for_each(|b| b.name = " Old ".into());
vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib);
assert_eq!(s.execute("brush.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy");
let names: Vec<String> = vectorcraft_brush::library(&s.doc().unwrap().doc).into_iter().map(|b| b.name).collect();
assert_eq!(names[names.len() - 6..], ["Nib", " Old ", "New Calligraphic Brush", "Quill", "Nib copy", "Old copy"]);
}
/// The Brushes panel's multi-selection: `names` deletes or duplicates several brushes in one undo
/// step, and an unknown name changes nothing.
#[test]
@@ -541,6 +567,23 @@ fn delete_duplicate_and_replace_symbols() {
assert!(s.execute("symbol.delete", &json!({"name": "Small"})).is_err());
}
/// New Symbol and Duplicate Symbol trim the name they are given, as Symbol Options trims
/// `newName`; the trimmed name is the one compared with the symbols there are.
#[test]
fn new_and_duplicate_symbols_trim_their_names_as_a_rename_does() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert_eq!(s.execute("symbol.new", &json!({"name": " Box \t"})).unwrap()["name"], "Box");
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
assert_eq!(s.execute("symbol.new", &json!({"name": " Box"})).unwrap()["name"], "Box 2");
rect(&mut s, 40.0, 0.0, 10.0, 10.0);
assert_eq!(s.execute("symbol.new", &json!({"name": " "})).unwrap()["name"], "New Symbol");
assert_eq!(s.execute("symbol.duplicate", &json!({"name": "Box", "newName": " Logo\t"})).unwrap()["name"], "Logo");
assert_eq!(s.execute("symbol.duplicate", &json!({"name": "Box", "newName": " "})).unwrap()["name"], "Box copy");
let names: Vec<String> = s.doc().unwrap().doc.symbols.iter().map(|x| x.name.clone()).collect();
assert_eq!(names, ["Box", "Box 2", "New Symbol", "Logo", "Box copy"]);
}
#[test]
fn symbol_export_type_defaults_to_movie_clip_and_round_trips() {
let mut s = session();
@@ -702,3 +745,26 @@ fn symbol_registration_places_the_chosen_point_on_xy() {
assert!(close(bounds(&s, p), Rect::new(300.0, 200.0, 380.0, 240.0), 1e-9), "{:?}", bounds(&s, p));
assert!(s.execute("symbol.new", &json!({"registration": 9})).is_err());
}
/// Symbol Options › 9-slice guides: off by default, stored per symbol, follow rename/duplicate, validated.
#[test]
fn symbol_nine_slice_guides_round_trip() {
let mut s = session();
let (_, name, _) = make_symbol(&mut s);
assert!(s.execute("symbol.list", &json!({})).unwrap()["symbols"][0]["nineSlice"].is_null());
assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_NINE_SLICE).is_none());
assert!(s.execute("symbol.options", &json!({"name": name, "nineSliceGuides": [0.6, 0.2, 0.6, 0.2]})).is_err());
assert!(s.execute("symbol.options", &json!({"name": name, "nineSlice": "yes"})).is_err());
let r = s.execute("symbol.options", &json!({"name": name, "nineSlice": true})).unwrap();
assert_eq!(r["nineSlice"].as_array().unwrap().len(), 4);
s.execute("symbol.options", &json!({"name": name, "nineSliceGuides": [0.25, 0.1, 0.25, 0.1]})).unwrap();
let copy = s.execute("symbol.duplicate", &json!({"name": name})).unwrap();
assert_eq!(copy["nineSlice"], json!([0.25, 0.1, 0.25, 0.1]));
s.execute("symbol.options", &json!({"name": name, "newName": "Panel"})).unwrap();
let g = s.doc().unwrap().doc.symbol_nine_slice("Panel").unwrap();
assert_eq!((g.left, g.top), (0.25, 0.1));
assert!(s.doc().unwrap().doc.symbol_nine_slice(&name).is_none());
s.execute("symbol.options", &json!({"name": "Panel", "nineSlice": false})).unwrap();
s.execute("symbol.delete", &json!({"name": "Box copy"})).unwrap();
assert!(s.doc().unwrap().doc.unknown.get(vectorcraft_doc::SYMBOL_NINE_SLICE).is_none());
}
+28
View File
@@ -908,6 +908,34 @@ fn traced_paths(n: &Node) -> Vec<Arc<Node>> {
n.children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).cloned().collect()
}
#[test]
fn a_large_image_traces_with_a_warning() {
// The public documentation: images over 1500 px on a side are traced, only more slowly.
let mut s = session();
let art = vectorcraft_trace::Raster::from_fn(
1600,
40,
|x, y| if (100..1500).contains(&x) && (10..30).contains(&y) { [0, 0, 0, 255] } else { [255; 4] },
);
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
assert_eq!(r["paths"], 1);
let w = r["warnings"].as_array().unwrap();
assert_eq!(w.len(), 1, "{w:?}");
assert!(w[0].as_str().unwrap().contains("1600 × 40") && w[0].as_str().unwrap().contains("1500"), "{w:?}");
// Make and Expand says so too.
let r = s.execute("imageTrace.makeAndExpand", &json!({"id": r["id"], "params": {"ignoreWhite": true}})).unwrap();
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1));
// At 1500 px and under, no warning.
let mut s = session();
image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({})).unwrap();
assert_eq!(r["warnings"], json!([]));
let edge = vectorcraft_trace::Raster::from_fn(1500, 8, |x, _| if x < 700 { [0, 0, 0, 255] } else { [255; 4] });
add_image(&mut s, &edge, Affine::IDENTITY);
assert_eq!(s.execute("imageTrace.make", &json!({})).unwrap()["warnings"], json!([]));
}
#[test]
fn image_trace_make_places_paths_over_the_image() {
let mut s = session();
+19
View File
@@ -585,6 +585,25 @@ fn in_place_pastes_of_art_off_the_artboards_stay_where_it_was() {
}
}
/// Art centered on an artboard's right or bottom edge is copied from that artboard: Paste in Place
/// puts it on the active artboard where it was on that one.
#[test]
fn in_place_pastes_of_art_on_an_artboard_edge_go_onto_the_artboard_named() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
let b = boards[1];
// A line along the right edge of artboard 2, then one along its bottom edge.
for (x1, y1, x2, y2) in [(b.x1, b.y0 + 10.0, b.x1, b.y0 + 40.0), (b.x0 + 10.0, b.y1, b.x0 + 40.0, b.y1)] {
let line = id_of(&s.execute("shape.line", &json!({"x1": x1, "y1": y1, "x2": x2, "y2": y2})).unwrap());
copy(&mut s, &[line]);
assert_eq!(s.clipboard.source_artboard, Some(b));
let ids = ids_of(&s.execute("edit.pasteInPlace", &json!({"artboard": 0})).unwrap());
let moved = doc(&s).bounds_of(&ids, false).unwrap().origin() - doc(&s).bounds_of(&[line], false).unwrap().origin();
assert_eq!(moved, boards[0].origin() - b.origin());
}
}
#[test]
fn paste_remembers_layers_restores_the_source_layers() {
let mut s = session();
+32
View File
@@ -97,6 +97,38 @@ fn exported_pdf_pastes_back_as_vectors() {
assert!(t.execute("clipboard.importPdf", &json!({})).is_err());
}
#[test]
fn the_png_and_pdf_say_when_an_image_or_a_font_is_missing() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let warnings = |r: &Value| -> Vec<String> { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() };
let mut s = session();
copy_red_square(&mut s);
assert_eq!(run(&mut s, "clipboard.exportPng", json!({}))["warnings"], json!([]));
assert_eq!(run(&mut s, "clipboard.exportPdf", json!({}))["warnings"], json!([]));
// Type in a family that isn't installed: the PNG and the PDF each name it.
let text = run(&mut s, "text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [text]}));
run(&mut s, "edit.copy", json!({}));
let png = warnings(&run(&mut s, "clipboard.exportPng", json!({})));
assert!(png.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{png:?}");
let pdf = warnings(&run(&mut s, "clipboard.exportPdf", json!({})));
assert!(pdf.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{pdf:?}");
// Copy's formats carry the notes of both, each once.
let (flavours, notes) = s.clipboard.flavours_with_warnings(false, true);
assert_eq!(flavours.iter().map(|f| f.mime).collect::<Vec<_>>(), [TEXT, PDF, PNG]);
for w in png.iter().chain(&pdf) {
assert_eq!(notes.iter().filter(|n| *n == w).count(), 1, "{w} in {notes:?}");
}
// An image the renderer can't decode: the PNG leaves it out and says so.
let image = crate::tests_menucmds::place_undecodable_png(&mut s);
run(&mut s, "select.set", json!({"ids": [image.0]}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(warnings(&run(&mut s, "clipboard.exportPng", json!({}))), [NOTE]);
let (flavours, notes) = s.clipboard.flavours_with_warnings(false, false);
assert_eq!(flavours.iter().map(|f| f.mime).collect::<Vec<_>>(), [PNG]);
assert_eq!(notes, [NOTE]);
}
#[test]
fn import_image_then_paste_gives_an_image() {
let mut s = session();
+22
View File
@@ -115,3 +115,25 @@ fn export_to_a_path_puts_the_pictures_next_to_the_sheet() {
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn export_says_once_when_a_rasterized_image_could_not_be_decoded() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let notes = |r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str() == Some(NOTE)).count();
let mut s = session();
run(&mut s, "shape.star", json!({"cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
assert_eq!(notes(&run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}))), 0);
// Two images the renderer can't decode, each rasterized to a picture of its own.
crate::tests_menucmds::place_undecodable_png(&mut s);
crate::tests_menucmds::place_undecodable_png(&mut s);
let r = run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}));
assert_eq!(r["images"].as_array().unwrap().len(), 3, "{r}");
assert_eq!(notes(&r), 1, "{}", r["warnings"]);
let dir = std::env::temp_dir().join(format!("vc-css-undecoded-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let r = run(&mut s, "css.export", json!({"path": dir.join("site.css").to_string_lossy(), "scope": "all", "rasterize": true}));
assert_eq!(r["images"].as_array().unwrap().len(), 3, "the pictures are still written");
assert_eq!(notes(&r), 1, "{}", r["warnings"]);
std::fs::remove_dir_all(&dir).ok();
}
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff. Show more