Compare commits

..
Author SHA1 Message Date
skylar taylor-barrick b12a52f724 PDF.R1: integrate guarded shared PDF writing and reading (#1894)
Integrate the shared PDF writer and reader as one qualified final tree. Preserve the separate writer and reader commits and review records.

All eight local CI gates and all eight hosted jobs passed; the checked source tree matches the final integration.
2026-10-14 22:46:16 -06:00
skylar taylor-barrick 05f6e2e6df PDF.R1: reconcile shared PDF migration with current main
Preserve the writer and reader changes while integrating the current main.

The combined candidate passes all eight local CI gates and the 2,048-case
reader panic verification. Hosted checks remain required before landing.

Refs #1893, #1894.
2026-10-14 22:02:49 -06:00
skylar taylor-barrickandskylartaylor 0946bc5b74 ci: add comfortable runtime limits to unbounded jobs (#2077)
Co-authored-by: skylartaylor <[email protected]>
2026-10-15 05:16:14 +02:00
Brandon ThomasandClaude Opus 5.5 0a33cff3f6 Release 0.11.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 09:05:33 -04:00
skylar taylor-barrick d80950b3f3 CI: isolate the font cache test from parallel font loads 2026-10-14 06:40:06 -06:00
skylar taylor-barrick 664e2cec5f CI: keep command comparison fixtures independent of the clock 2026-10-14 05:10:20 -06:00
skylar taylor-barrick 2ecba1fb9c PDF.R1: reconcile shared reader with current writer 2026-10-14 03:59:03 -06:00
skylar taylor-barrick 52960faf63 PDF.W1: reconcile shared writer with current main 2026-10-14 01:23:19 -06:00
BFlat a067177778 Merge pull request #1223 from dotCooCoo/feat/shortcut-sets
Keyboard Shortcuts: Import… and File › Open read text shortcut sets (.kys)
2026-10-14 14:12:14 +07:00
bflatastic d9c9526898 Keyboard Shortcuts: shortcut set reading and matching stay bounded on hostile files
A set whose entries sit in long, deeply nested sections kept every section's names again for each entry (up to hundreds of megabytes from a 4 MiB file); the names kept over all entries now stop at 8 MiB. Name matching compares character pairs in linear time, so a 20,000-entry set of long names is matched in about 2 s instead of 17 s.
2026-10-14 13:49:59 +07:00
dotCooCoo 8c889279f8 Keyboard Shortcuts: importMatches.list answers an unknown parameter key with an error
Since #1442 (3acc29f8), a caller outside the app gets an error for a
parameter key that the command's params description doesn't name, and
every_description_parses requires each top-level entry of the params
object to start with a key. The note on query in importMatches.list had
a comma at the top level ("text in the name, target or label"), so the
description didn't parse: the test failed, and the command took any key.
The note is now in parentheses, and the command takes kind and query.
A test checks that shortcutSet.read and the three importMatches
commands take every key they read and refuse a misspelled one.
2026-10-14 13:22:08 +07:00
dotCooCoo d1108bdbca i18n: Korean, Polish and Vietnamese rows for Import Shortcut Set and its commands
Since #1352, #1498 and #1507, Korean, Polish and Vietnamese are complete
catalogs: every menu string, tl! literal and status or error message has
a row, and the i18n tests check it. ko.tsv, pl.tsv and vi.tsv get the
rows that the other complete catalogs have for Import Shortcut Set and
its commands: the dialog's strings, the labels Import Shortcut Set…,
Read Shortcut Set File, Import Matches, Remember Import Matches and
Forget Import Matches, the two plurals and the two error messages. Copy
Report and Not read are already in all three, and Shortcut and the
Imported message in pl, so they are not repeated. The dialog's rows go
after Import Print Presets in ko and vi and after the Keyboard Shortcuts
dialog's rows in pl, and the two messages after "not a VectorCraft
shortcut set".
2026-10-14 13:22:08 +07:00
dotCooCoo 04244fa2a2 i18n: remove Import Shortcut Set rows that the catalogs already have
Check Script (3374d077) added Copy Report and Not read to every catalog,
and es and fr gained Shortcut and the Imported message in a747daa3 and
9e39a6d1. The rows that Import Shortcut Set added for the same strings
became second entries, which bundled_catalogs_are_consistent rejects.
They are removed, 26 rows in cs, de, es, fr, hu, it, ja, ru, uk, zh-hans
and zh-hant, and the existing rows stay as they are.
2026-10-14 13:22:08 +07:00
dotCooCoo 612a13b39c i18n: Hungarian rows for Import Shortcut Set and its commands
Since b9716366 (#1302), Hungarian is a complete catalog: every menu
string, tl! literal and status or error message has a hu row, and the
i18n tests check it. hu.tsv gets the rows that the other complete
catalogs have for Import Shortcut Set and its commands: 35 strings, the
two plurals in the single form hu.tsv uses (a count takes the
singular), and the two error messages. Shortcut and the Imported
message already have hu rows on main, so they are not repeated. The
rows are placed after the Keyboard Shortcuts… row.
2026-10-14 13:22:08 +07:00
dotCooCoo f1e634af19 i18n: Czech rows for Import Shortcut Set and its commands
Since #1189, Czech is a complete catalog: every menu string, tl! literal
and status or error message has a cs row, and the i18n tests check it.
cs.tsv gets the rows that the other complete catalogs have for Import
Shortcut Set and its commands: 35 strings, the two plurals with one, few
and other forms, and the two error messages. Shortcut and the Imported
message already have cs rows on main, so they are not repeated. The rows
are placed after the Keyboard Shortcuts… row.
2026-10-14 13:22:08 +07:00
dotCooCoo 8c14609370 Keyboard Shortcuts: Import… and File › Open read text shortcut sets (.kys)
A shortcut set another app saved as slash-name text (a .kys file whose
text starts with a slash name and "{", such as "/Menus {") opens in
Import Shortcut Set from File › Open, from Edit › Keyboard Shortcuts ›
Import… in the desktop app, or from a drop on the window, whether or
not a document is open. The dialog proposes a target for an entry when
an earlier import kept one for that name, or else when one tool,
command or panel matches the name clearly by its id or by one of its
labels. It decodes the file's key and modifier codes from the codes
earlier imports kept, from the example in the AiCommandPalette README
(/Key 48 for the 0 key, with /Modifiers 0 taken as no modifiers), and
from VectorCraft's default shortcuts where at least two entries vote and
four votes in five agree. The user picks the remaining targets, presses
the key for each unknown key code or marks it No equivalent, and chooses
the modifiers for each unknown modifier code. Apply assigns the ready
rows with the Keyboard Shortcuts rules: a tool takes a single key,
optionally with Shift. It goes through the rows from the last one back
and leaves a row out when a later row it applies has the same shortcut
or the same tool, command or panel. Keyboard Shortcuts then shows the
result as unsaved changes until the user clicks OK. Copy Report copies
the set's name, the rows Apply leaves out and the parts of the file that
weren't read.

- shortcutSet.read is a query command: it reads a set and does not
  modify the document or the preferences. It returns each entry with
  its section, codes and line, and up to 1,000 skipped tokens, each cut
  to 80 characters. It reads at most 4 MiB of text and 20,000 entries,
  skips blocks nested more than 16 deep and cuts names at 1,024 bytes.
  With a path it reads at most 4 MiB and 4 bytes of the file, and with
  --automation-read-root (#832) the path must lie inside the read root.
  A file whose text starts with "<", such as an XML shortcut file saved
  as UTF-8, fails with "This shortcut file is in a form VectorCraft
  can't read."
- importMatches.list, importMatches.set and importMatches.reset read
  and change the matches kept with the preferences; resetting the
  preferences keeps them. With Remember My Choices on (the default),
  Apply keeps the targets and codes the user chose, and for the rows it
  assigns, it also keeps the matched targets and the codes decoded by
  agreement. A later import proposes them.
- The name matcher compares a name with ids by exact match and shared
  words. It compares the name with labels, in English and in the
  interface language, by exact match, shared words and character
  pairs. It proposes a match only when one tool, command or panel
  scores at least 0.6 and at least 0.1 more than the next one.
- When Import Shortcut Set is opened from Keyboard Shortcuts, Cancel,
  Escape and ui.dialog.cancel close it and return to Keyboard Shortcuts
  as it was (dialogs::PARENT). A document's queued Missing Fonts dialog
  (#792) opens after Keyboard Shortcuts closes.
- File › Open has a "Shortcut sets" filter, and in the desktop app
  Keyboard Shortcuts' Import… shows .json and .kys files.

Docs: docs/mcp.md, docs/control-protocol.md, docs/development.md,
docs/target-app-parity.md, docs/roadmap.md.
2026-10-14 13:22:07 +07:00
BFlat b076025a31 Merge pull request #1897 from storytold/fix/1881-typing-cmd-drag
Fix #1881: a Ctrl/Cmd drag while typing keeps what was typed
2026-10-14 12:38:12 +07:00
BFlat 3bb87989df Merge pull request #1896 from storytold/fix/pattern-tiles-brush-cms
Pattern tiles after brush edits and Color Settings; MCP docs on failed batches
2026-10-14 12:38:00 +07:00
BFlat a40e8f5aff Merge pull request #1895 from storytold/fix/1866-section-highlight
Fix #1866: Preferences and Save PDF highlight the section shown
2026-10-14 12:37:48 +07:00
BFlat 1a8e0456cb Merge pull request #1880 from marcoarnulfo/fix/tab-leader
Text: draw tab leaders in the tab's gap
2026-10-14 12:37:27 +07:00
bflatastic 7f1ad31a7b Fix #1881: a Ctrl/Cmd drag while typing keeps what was typed
With the Type tool editing, a press with Ctrl (Cmd on the Mac) lends the
Selection tool for the drag. Its drag joined the open typing session, so its
preview started from the document before the typing: the type moved or
scaled and lost what had just been typed. The typing is now kept as its own
undo step before the lent tool takes the press, as Undo already does, and
typing goes on after the drag.
2026-10-14 12:27:04 +07:00
bflatastic 6fd31d4dbd MCP docs: a failed batch leaves the files its steps wrote
The rollback of a failed command.batch (#1862) puts the documents back, not
the disk. Say so, and how to keep a batch from writing unless it succeeds.
2026-10-14 12:27:04 +07:00
bflatastic f9b051d3d1 Fix #1866: Preferences and Save PDF highlight the section shown
The section lists asked for a selectable button and then turned its frame
off while the pointer was elsewhere, which also dropped the selected row's
fill: the section shown looked like every other row. The selected row now
keeps its fill.
2026-10-14 12:27:04 +07:00
bflatastic 6bc0222074 #1723 follow-up: pattern tiles are drawn again after a brush edit or new Color Settings
A tile was kept while the pattern's art and what it shows were unchanged,
but a brush the art is stroked with lives in the document's other data, and
every tile is drawn through the color settings. A tile whose art reads more
than itself now also remembers the document's other data, and every tile
remembers the color settings it was drawn through (both weakly).
2026-10-14 12:27:04 +07:00
Marco Arnulfo eff2ccdc34 Text: draw tab leaders in the tab's gap (fixes #1877) 2026-10-14 12:27:03 +07:00
BFlat c73871eacc Merge pull request #1891 from storytold/fix/1691-shape-builder-gaps
Shape Builder: the gaps closed paths enclose are regions (#1691)
2026-10-14 12:14:45 +07:00
BFlat 2afd2654e8 Merge pull request #1859 from dotCooCoo/feat/proof-preview-prepared-document
Render: the art prepared for Separations Preview and Overprint Preview is kept between frames
2026-10-14 12:14:20 +07:00
BFlat 725c3881d4 Merge pull request #1860 from dotCooCoo/feat/web-build-clock
Web: the status bar shows measured render and UI times instead of 0.0 ms
2026-10-14 12:14:03 +07:00
BFlat aa93258ea7 Merge pull request #1861 from dotCooCoo/fix/hidden-chars-release-when-off
Type: Show Hidden Characters keeps no text nodes while it is off or no document is open
2026-10-14 12:13:42 +07:00
BFlat fe735f0e7d Merge pull request #1890 from storytold/docs/progress-1014b
Docs: ROADMAP and parity docs for the later 2026-10-14 merges (#1630–#1875); gaps noted for reporters
2026-10-14 12:13:08 +07:00
bflatastic 65e2168791 Shape Builder: the gaps closed paths enclose are regions (#1691) 2026-10-14 11:28:21 +07:00
dotCooCoo c44ece3113 Render: the art prepared for Separations Preview and Overprint Preview is kept between frames
With Separations Preview on, the renderer copied every node of the
document in every frame and separated each of its colors again. With
Overprint Preview on, it copied each object with an overprinting fill
or stroke in every frame, with the groups and layers that hold it. The
copies were new Arcs, so the geometry, stroke outline and raster
caches missed for each of them in every frame. In a release build,
drawing a 2880x1800 frame of a 50,000-path document again with the
same view and Separations Preview took 795 to 908 ms.

The renderer now keeps the art it prepared and what it was made from,
and in the next frame it uses that art again for each layer, symbol
art and member that has not changed. Only the objects that changed are
prepared again, with the layers and groups that hold them. All of the
art is prepared again when Discard White Overprint changes and, with
Separations Preview, when the plates shown, the proof setup, the
swatches, the Spot Colors option or the color settings change or a
profile is registered (cms::profiles_generation). The renderer now
draws that frame again in 98 to 107 ms, with the same pixels.

Docs: docs/target-app-parity.md.
2026-10-14 11:22:41 +07:00
dotCooCoo 014021577b Web: the status bar shows measured render and UI times instead of 0.0 ms
In the web build, now_ms() in crates/ui-egui/src/lib.rs returned 0.0.
The status bar read "render 0.0 ms · ui 0.0 ms" however long a frame
took, and the Layers panel's budget of 8 ms for the row thumbnails it
renders in a frame never ran out, so only its limit of 16 thumbnails
applied.

now_ms() now returns the milliseconds since its first call, read from
the browser's performance.now() through web-time, which the build
already has through eframe. The desktop clock is unchanged. In Chrome,
after a pan frame of 750 ms over a document of 50,000 paths, the status
bar now reads "render 750.7 ms". The Layers panel now renders the
thumbnails of 24 groups of 2,000 paths one per frame, where main
rendered nine of them in one frame of 217 ms. The canvas has no render
worker in the web build, so it still renders on the UI thread and draws
the same pixels.

Docs: docs/target-app-parity.md.
2026-10-14 11:22:41 +07:00
dotCooCoo abe89d5176 Type: Show Hidden Characters keeps no text nodes while it is off or no document is open
Show Hidden Characters keeps each text object's marks together with
the node it laid them out from. Only a frame that drew the overlay
changed what it kept. After the option was turned off, or the
document was closed, those nodes stayed alive until the overlay was
next drawn.

The canvas now takes the kept marks at the start of each frame and
puts them back only when it draws the overlay. A frame that does not
draw it, for example with the option off or with no document open,
drops the marks and their nodes. A frame that draws the overlay still
keeps the marks of the frame before.
2026-10-14 11:22:41 +07:00
bflatastic 0c0d2448da Docs: ROADMAP and parity docs for the later 2026-10-14 merges (#1630–#1875); gaps noted for reporters; macOS save sheet Format menu back to missing 2026-10-14 11:22:40 +07:00
skylar taylor-barrick e01a65d8a9 PDF.R1: integrate shared reader with bounded nesting checks 2026-10-13 21:58:01 -06:00
BFlat 5a120e323b Merge pull request #1875 from storytold/xtask/web-wasm-check
xtask: the wasm check compiles the web app; the web's SVG depth cap follows from its stack
2026-10-14 10:29:29 +07:00
BFlat 5de774835b Merge pull request #1874 from storytold/fix/krilla-no-simple-text
PDF: krilla without its text shaping, so rustybuzz and ttf-parser don't ship (#1730)
2026-10-14 10:29:19 +07:00
BFlat 1a280e1402 Merge pull request #1873 from storytold/fix/pdf-hostile-streams
Importers: PDF streams the reader would fail on are refused (#1700, #1701)
2026-10-14 10:29:09 +07:00
BFlat 8bd8e2b298 Merge pull request #1872 from storytold/fix/brushes-drawn-by-name
Importers: a brush that draws itself by name no longer crashes
2026-10-14 10:28:57 +07:00
BFlat cd1b590aa8 Merge pull request #1871 from storytold/fix/pdf-nested-containers
Importers: PDF nesting inside compressed streams no longer crashes (hayro 0.8)
2026-10-14 10:28:48 +07:00
BFlat dcd0da87cb Merge pull request #1870 from storytold/fix/svg-link-scope
Importers: an SVG's linked files are read within the automation roots again
2026-10-14 10:28:37 +07:00
bflatastic f774968b20 xtask: the wasm check compiles the web app; the web's SVG depth cap follows from its stack
`cargo xtask wasm` (part of `cargo xtask ci`) checked the wasm-safe crates but not
apps/vectorcraft-web, so web-only code was never compiled before a merge. It now runs
`cargo check --target wasm32-unknown-unknown -p vectorcraft-web` too (no Trunk, no network).

The SVG importer's cap on the web was 48, sized from native measurements. rustc links the web
build with a 1 MiB stack (`-z stack-size=1048576`; the app passes no linker arguments of its
own) and usvg uses about 15 kB a level: half the stack at that cost gives 34, set in one place
with the arithmetic.
2026-10-14 10:13:45 +07:00
bflatastic f1fd8e85e8 PDF: krilla without its text shaping, so rustybuzz and ttf-parser don't ship (#1730)
krilla's default `simple-text` feature is the only thing that brings in rustybuzz and
ttf-parser, which RustSec lists as unmaintained. The PDF writer places glyphs itself, so the
workspace takes krilla with `raster-images` only; the one test that writes files with
`draw_text` turns `simple-text` on as a dev-dependency feature.
2026-10-14 10:13:45 +07:00
bflatastic 991ce2c635 Importers: PDF streams the reader would fail on are refused (#1700, #1701)
hayro (0.7.2 and 0.8.0 alike) inflates Flate and LZW data with no ceiling, so a few megabytes
of zeros ask for gigabytes, and applies a predictor before checking its parameters: Columns 0
divides by zero (a panic, an abort on the web), a huge Columns allocates a row that large (an
abort everywhere). Both happen inside the reader, before any of our limits.

A file is refused ("a stream in it would decode to more than 256 MB, or its decode parameters
can't be applied") when a stream's decode parameters ask for a predictor the reader can't apply,
or when it decodes past 256 MB through Flate, LZW, run-length and the ASCII encodings before
them, decoded here with a ceiling (only streams large enough to get there are decoded). Every
object's stream is looked at once the file is open (decrypted, references followed), with the
inline images of anything that can be content; object and cross-reference streams, which the
reader decodes while it opens the file, are looked at first in the file's text.
2026-10-14 10:13:45 +07:00
bflatastic 0efcaeb558 Importers: a brush that draws itself by name no longer crashes
A scatter brush whose art is an instance of a symbol stroked with that brush, or any brush whose
art is painted with a pattern whose tile is stroked with it, drew itself without end: a stack
overflow in a hand-written native file. Brush pieces already strip the brushes of their own
strokes; now what they draw by name that would draw a brushed stroke (a symbol instance, or a
pattern paint, also through the symbols and patterns those use) is left out too, in one place
(`stroke_pieces`, which every renderer and exporter calls with the document).

Probed the rest of item 2 of the depth follow-up with hand-written hostile inputs, kept as tests:
graph designs of deep art and of a symbol holding itself, Affinity groups nested to and past
both of its reader's caps, DXF blocks inserting themselves, EMF/WMF saving states without end,
and plug-in manifests and output nested 30,000 to 100,000 deep. None of them overflowed.
2026-10-14 10:13:45 +07:00
bflatastic 17d2493616 Importers: PDF nesting inside compressed streams no longer crashes (hayro 0.8)
hayro-syntax 0.8.0 reads arrays and dictionaries 64 deep and no deeper wherever they are (the
file's text, object streams, the content of pages, forms, patterns, glyphs and masks), walks the
page tree without recursion and follows a chain of object streams once: what 0.7.2 overflowed
the stack on. hayro-interpret 0.8.0 comes with it (a new device interface: the importer's device
takes it as before). Our raw pre-scan of the file's text and page content goes: the reader's own
cap covers more, and the scan miscounted the bytes of unfiltered binary streams.

What 0.8.0 still builds by recursion with no cap is refused when the file opens: functions and
colour spaces built on themselves or on one another more than 16 deep, and calculator functions
whose procedures nest more than 64 deep. The import runs on a 16 MB thread, as the SVG importer
does.

hayro-syntax 0.8 turns on flate2's zlib-rs backend, which the whole app then deflates with: a
compressed native file is the same gzip stream as before (header and data), but its deflate
blocks now depend on how the data is fed, so the streaming test checks the header and the
unpacked data instead of the bytes.
2026-10-14 10:13:45 +07:00
bflatastic 3ae71c16ae Importers: an SVG's linked files are read within the automation roots again
The SVG importer runs on a thread of its own since #1852 (for its stack), and the automation
roots in force on the thread that opened the file didn't hold there: an agent confined to a
read root could have the app read any file an SVG links to. The reads now run in the scope of
the thread that asked.
2026-10-14 10:13:45 +07:00
BFlat ecf86ca21e Merge pull request #1865 from storytold/fix/1685-save-failure-dialog
Fix #1693, #1694, #1685: a failed save is reported in a dialog that stays; failed downloads; opens under a dialog
2026-10-14 09:51:48 +07:00
BFlat 886b33c584 Merge pull request #1864 from storytold/fix/audit-render-effects
Fix #1706, #1705, #1724, #1723, #1683: shadow raster budget, Transform copies, 3D fill rule, pattern tiles, template Dim Images
2026-10-14 09:51:34 +07:00
BFlat 2af6f5bfbe Merge pull request #1863 from storytold/fix/audit-importers
Fix #1715, #1714, #1704, #1699, #1695: bounded ASCII85 and unpacking, EPS cycles, Affinity pixel allowance, save fallback
2026-10-14 09:51:24 +07:00
BFlat cde9ee8a71 Merge pull request #1862 from storytold/fix/audit-ids-batch-control
Fix #1718, #1697, #1725, #1717: id exhaustion, batch rollback, timed-out control requests, RGBA size before decoding
2026-10-14 09:51:14 +07:00
skylar taylor-barrick a699a26221 refactor(pdf): use shared writer for export and print (PDF.W1) 2026-10-13 20:41:14 -06:00
bflatastic e802ea8438 Fix #1685: a save or export that fails is reported in a dialog that stays
A save that couldn't write its file (a read-only folder, a file another
program holds open, a full disk) was only reported in the status bar, a line
the next message replaces; a background save's failure arrived there some
time after the user had moved on. Save, Save As, Save a Copy, Save as
Template, Export As and the commands that write one file now also say it in a
dialog naming the file and the reason, which stays until it is closed. The
document stays modified and keeps the file it had. A message that arrives
while another dialog is open waits for it to close instead of replacing it.
An agent's request gets the error as its reply, without a dialog; a failed
recovery write stays in the status bar.
2026-10-14 09:03:51 +07:00
bflatastic 8f8d49110f Fix #1694: a file that finishes opening waits while a dialog is open
A document opened off the UI thread became the active one as soon as it was
ready, also under an open dialog, whose OK acts on the active document: Save
As options confirmed after another file had finished opening wrote that
file's art under the first document's name. Files that are ready now wait
until the dialog is closed.
2026-10-14 09:03:32 +07:00
bflatastic 5ecd045156 Fix #1693: a browser download that can't be started fails the save it is for
The web app's download callback returned nothing, so a save whose Blob, URL or
anchor couldn't be made was reported as written: the document counted as
saved, its recovery copy was dropped, and Save Changes closed it. The callback
now returns the error, which fails the save, export or file command like a
refused write on the desktop. A timer that can't be set after the download
started is not a failure.
2026-10-14 09:03:31 +07:00
bflatastic 5688294d56 Fix #1683: a template layer dims its images by Dim Images, not the whole layer
The canvas faded a template layer as a whole by a fixed half and ignored its
Dim Images option, so unchecking it or setting another percentage changed
nothing, and the layer's paths were faded with its images. A template layer's
images are now dimmed to its Dim Images percentage (50 % when Layer Options
makes it a template), shown at full strength with the option off, and its
vector art is drawn as it is.
2026-10-14 09:02:28 +07:00
bflatastic c348b384aa Fix #1723: a pattern's tile is drawn again when what its art shows changes
A tile was kept while the pattern's own art, tile and tiling were the ones it
was drawn from. Art that shows a symbol, is painted with another pattern or
holds an image or type doesn't change when those do, so the pattern kept
drawing the old symbol (in every renderer on the thread: the tiles are
shared). Such a tile now also remembers the document's pattern and symbol
art, image pixels and fonts, and is drawn again when they differ. A raster
effect on an object painted with a pattern reads all of that too, as one on
a symbol instance does.
2026-10-14 09:02:28 +07:00
bflatastic a6d5c461a1 Fix #1724: 3D effects on a nonzero compound path keep the area its contours fill
Extrude & Bevel, Inflate and Rotate built their solid from the outline by
nesting alone: a contour inside an odd number of others was a hole. Under the
nonzero rule a contour inside another that runs the same way encloses filled
art, so the effect cut a hole the object doesn't have. The fill rule now
reaches the outline builder, which leaves out contours with filled art on
both sides before pairing outlines and holes.
2026-10-14 09:02:28 +07:00
bflatastic 779120cb5b Fix #1705: a Transform effect's copies are bounded by the anchors they make
Each Transform effect clamps its own copies to 1000, but each copies all of
what the effect before it made: three of them on one square ask for a
thousand million squares, from a small file. Copies now stop where the result
would pass two million anchors (1000 copies of a 2000-anchor path).
2026-10-14 09:02:28 +07:00
bflatastic 2e6b925793 Fix #1695: the copy-over fallback of a save keeps the old file until the new one is in
Where a folder refuses the temporary file or the rename, the new content
(whole and flushed elsewhere) is copied into the file itself: the file was
truncated first, so a copy cut short by a lost connection or a crash left
neither version in place, and only the new one on disk under a hidden name.
The file as it was is now copied and flushed beside the temporary file before
it is truncated; if it can't be, the file is left alone and the save fails,
naming the temporary file. A failed copy names both.
2026-10-14 09:00:42 +07:00
bflatastic 48041c8cd3 Fix #1706: a shadow or glow too large to cache is filtered within the pixel budget
The fallback for a drop shadow, outer glow or inner glow whose raster the
shadow cache refuses rendered the view plus the blur's spread at full
resolution, capped only at 65535 pixels a side: a 1000 pt blur at 3200 %
spreads 48000 pixels, so the surface was up to 16 GB whatever the window's
size. The region is now planned by filter_region, which renders it at a lower
resolution (as filtered content already is) to stay within PIXEL_BUDGET.
2026-10-14 09:00:42 +07:00
bflatastic 1d282159dd Fix #1699: opening a streamed native file unpacks a bounded number of bytes
The limits on a compressed file's entries were on their lengths. An entry of
one byte at a far offset unpacked everything before it, and each entry behind
the previous one started the stream over and unpacked the JSON and the gap
again: a small hand-made file could ask for hours of decompression. All of it
is now counted against one budget (the entries a writer can make, and the
JSON once more), past which the file is refused as too big.
2026-10-14 09:00:42 +07:00
bflatastic 0ad92e4649 Fix #1704: an Affinity document's pixel layers share one pixel allowance
Each pixel layer was held to 64 megapixels, and the archive's limits count
the bytes extracted from it; a layer whose tiles are flat colour needs none,
so a small file with many of them allocated 256 MB each without end. The
layers of one document now decode 512 megapixels (2 GB) at most together;
those past it are left out with a note, as an oversized one is.
2026-10-14 09:00:42 +07:00
bflatastic 729e5ced7f Fix #1714: the EPS interpreter frees arrays and dictionaries that hold themselves
Composite objects are reference-counted, so `/a 1 array def a 0 a put` (or two
dictionaries naming each other) outlived the import: emptying the standard
dictionaries and the stacks doesn't break a cycle, least of all one the
program no longer reaches. Up to the interpreter's whole memory budget could
stay allocated per imported file. The interpreter now lists the arrays and
dictionaries it makes (weakly, swept as they are dropped) and empties the
ones still alive when it is done.
2026-10-14 09:00:42 +07:00
bflatastic 0cb55bc0aa Fix #1717: sampling and recolouring check an image's RGBA size before decoding
The decoder's 512 MB limit is on the file's samples; a grey image within it
is four times that once converted to RGBA for the Eyedropper or a recolour,
which the web build can't allocate. The size is now read from the header and
held to the allowance the importers and the renderer use.
2026-10-14 08:47:10 +07:00
bflatastic 277e893e82 Fix #1715: ASCII85 decoding is bounded while it decodes
A `z` is four bytes for one character, so the text's length didn't bound
the result, and the filter's and the interpreter's budgets only saw it once
it was whole. The decoder now stops at 256 MB, the most one filter or data
section may hold.
2026-10-14 08:47:10 +07:00
bflatastic ea30e411e7 Fix #1725: a control request its caller timed out on is withdrawn, not run later
The worker answered "timeout" after 60 s and left the request on the queue, so
the app ran it whenever it next drained the queue: an edit nobody was waiting
for, and a second one if the caller had retried. A request now leaves the
queue once: taken by the app, or given up by its caller, whichever comes
first. The timeout reply says which ("will not run" / "still running").
2026-10-14 08:47:10 +07:00
bflatastic 355bb5c4f6 Fix #1697: a failed command.batch restores each document whole
The rollback undid the batch's interaction, which holds the art, the selection
and panel state only: a Save As step's new file, format and saved state stayed,
and an Undo step (which ends the interaction) left the art and the history
undone. The batch now keeps the documents as they were and puts them back,
and settles a drag or typing in progress first, as Undo does, instead of
running inside it and keeping its baseline as the batch's step. Files the
steps wrote stay written.
2026-10-14 08:47:10 +07:00
bflatastic 7aa00b41da Fix #1718: ids at their largest value in a file no longer exhaust the counters
A native file (or a pasted document) whose node, slice or asset ids or whose
next-id counter sit at u64::MAX made the next allocation overflow: a panic
with overflow checks, a wrapped counter handing out ids already in use
without. fix_next_id now leaves ids and counters from 2^62 up out of the
count, so new ids start under them; alloc_id saturates. Artboard and graphic
style ids take the smallest free id when the largest is u32::MAX instead of
wrapping to the reserved 0.
2026-10-14 08:47:10 +07:00
BFlat f3a3224745 Merge pull request #1839 from marcoarnulfo/fix/transform-panel-anchors
Transform panel: X/Y/W/H edit the anchors selected with Direct Selection
2026-10-14 08:38:19 +07:00
BFlat e6bf586556 Merge pull request #1840 from mjohnson7cd/fix/1816-snap-to-grid
fix(snap): snap Direct Selection anchor drags to grid intersections (#1816)
2026-10-14 08:38:03 +07:00
BFlat 263d2f23c8 Merge pull request #1850 from dotCooCoo/chore/docs-perf-and-gaps-facts
Docs: G15 and the parity doc cite budget runs and slowness reports since 2026-10-11
2026-10-14 08:37:41 +07:00
bflatastic 61ef472e71 object.setBounds: one helper for the asked width and height 2026-10-14 08:17:14 +07:00
Marco Arnulfo 4ef0cb76e6 Transform panel: X/Y/W/H edit the anchors selected with Direct Selection (fixes #1824) 2026-10-14 08:16:26 +07:00
Mara Johnson 891a58eff8 fix(snap): snap Direct Selection anchor drags to grid intersections (#1816) 2026-10-14 08:16:24 +07:00
dotCooCoo 318e6b07b7 Docs: G15 and the parity doc cite budget runs and slowness reports since 2026-10-11
The "Performance unverified" bullet in docs/target-app-parity.md said
that the budgets had not been run on an idle machine since 2026-10-01,
that the last loaded run missed the 16 ms frame budget at 50k paths,
and that no user had reported slowness in mainstream work beyond #575
and #758. On 2026-10-11 #1155 recorded idle-machine runs, made before
#1173 merged, and runs of #1173's change met every budget at 50k paths
(load average 4 to 7) and at 25k paths. #1097 (zoom and pan in 0.9.0)
and #1731 (a drag that stutters as it starts) are open. G15 in
docs/gaps.md called the 25k-path run the last one, but on 2026-10-13 a
comment on #1172 reported runs of 0.10.0 on Linux with 4 logical CPUs
that missed all three render budgets at 20k paths.

The bullet now gives the 25k-path run and the runs in #1172, and lists
#1097 and #1731 with #575 and #758 as open user reports of slowness.
G15 no longer calls the 25k-path run the last one and adds the runs in
#1172.
2026-10-14 08:16:23 +07:00
BFlat ba5447bbd9 Merge pull request #1855 from storytold/fix/1800-mac-save-sheet
Fix #1800: macOS Save As shows its sheet again
2026-10-14 08:12:38 +07:00
BFlat c3a830bc38 Merge pull request #1857 from storytold/fix/1747-prefs-on-ok
Fix #1747: Preferences are written when confirmed, not only at exit
2026-10-14 08:12:29 +07:00
BFlat b56400b5f9 Merge pull request #1856 from storytold/fix/1719-redo-interaction
Fix #1719: Redo keeps a drag in progress as a step, as Undo does
2026-10-14 08:12:19 +07:00
bflatastic 494ce39de5 Fix #1747: Preferences are written when confirmed, not only at exit
The preferences file was written only when the app quit normally, so settings
confirmed with OK were lost when the session ended any other way (the system
shutting down, the process being ended, a crash). OK now asks the host to
write the file at once.
2026-10-14 08:11:06 +07:00
bflatastic f97d57f859 Fix #1719: Redo keeps a drag in progress as a step, as Undo does
Redo swapped the document and the history under a live interaction, whose
snapshot was still the document from before: the next preview or a cancel
brought that one back while the history recorded the redo, so the redone edit
was lost. Redo now settles the interaction first, the way Undo already did: a
drag that changed something is kept as the newest step (which ends the steps
to redo), one that changed nothing is dropped.
2026-10-14 08:11:02 +07:00
bflatastic a60f7e1858 Fix #1800: macOS Save As shows its sheet again
The Format menu added to the save sheet after rfd began it (#866) made recent
macOS versions (26.6.2 and 27.0.1 reported) dismiss the sheet at once: Save As,
Save a Copy and Save of an untitled document flashed and answered as cancelled,
while panels without it (Open, Export As) worked. The accessory is withdrawn
until it can be set before the sheet is presented; the format still follows
the extension typed in the name field, as before #866.
2026-10-14 08:10:58 +07:00
BFlat 8fd0612069 Merge pull request #1836 from Muratv07/feature/marquee-select-guides
feat: marquee selection selects unlocked ruler guides
2026-10-14 08:10:45 +07:00
BFlat 3f5838843d Merge pull request #1833 from switch87/fix/1738-symbol-text-bounds
Native reopen: type in symbols gets its layout bounds back (#1738)
2026-10-14 08:10:33 +07:00
BFlat 1d62d75150 Merge pull request #1830 from switch87/fix/1735-mask-global-swatch
Global swatch edits reach opacity-mask art (#1735)
2026-10-14 08:10:15 +07:00
BFlat 7e3fc2d021 Merge pull request #1834 from saadkhantareen/fix/1703-svg-selector-backtracking
fix(svg): memoize descendant selector matching to stop combinatorial backtracking (#1703)
2026-10-14 08:09:53 +07:00
BFlat b45ceb9427 Merge pull request #1851 from storytold/docs/image-trace-1014
Docs: Image Trace Shapes rework, stroke caps, enhanced presets, gradients over noise
2026-10-14 08:02:09 +07:00
bflatastic ed96b2ff65 Marquee guide selection: one shared helper for both selection tools 2026-10-14 08:01:50 +07:00
Gert Pellin e54fe9c31e Native reopen: type in symbols gets its layout bounds back (#1738)
Type's layout bounds are a cache that isn't saved. On open they were
computed again only for type in the layers, so type in a symbol definition
(or a pattern tile, graph design or opacity mask) kept the rough estimate,
and an envelope warping it was fitted to a different box: warped type in a
symbol changed shape after a save and reopen. The refresh now goes through
every art tree, masks included.
2026-10-14 08:01:50 +07:00
Gert Pellin 904414aa7d Global swatch edits reach opacity-mask art (#1735)
A mask's art lives outside the layer tree (in the masked object's extras),
and the walks over linked colours skipped it: editing a global swatch
recoloured everything but the mask, which kept showing (or hiding) as
before, and a swatch used only in a mask counted as unused. The colour
walks now go into opacity masks: map_solid_paints (swatch edits, renames,
deletes, Merge and the like) and visit_paints (unused swatches).
2026-10-14 08:01:50 +07:00
Saad Abdullah ad165a5a00 fix(svg): memoize descendant selector matching to stop combinatorial backtracking (#1703) 2026-10-14 08:01:50 +07:00
Murat Vural 8796aa9d85 feat: marquee selection selects unlocked ruler guides 2026-10-14 08:01:50 +07:00
BFlat 225f3d7b0c Merge pull request #1751 from AyberkA0/feat/toolbar-drawer-groups
Fix #1736: toolbar without group labels, and an All Tools drawer with flyout groups, shortcuts, menu and Show section
2026-10-14 07:58:09 +07:00
BFlat 0ae6c288ea Merge pull request #1853 from storytold/fix/1629-actions-selection
Fix #1629: an action clicked in the Actions panel stays selected, so Play and Delete act on it
2026-10-14 07:57:26 +07:00
BFlat b25c106fbf Merge pull request #1852 from storytold/fix/deep-nesting
Importers: nesting is capped, so a deeply nested file can't overflow the stack
2026-10-14 07:57:01 +07:00
bflatastic 22118a3e8e Edit Toolbar: the drawer's groups have a named type (clippy's type-complexity) 2026-10-14 07:37:54 +07:00
Ayberk A. 8894da68d7 Part of #1736: one toolbar column runs on without group labels too
Without group labels, the single column no longer draws a dash between
the groups: its tools are evenly spaced from top to bottom, as two
columns already run on from group to group. With the labels, each group
still starts under its name.
2026-10-14 07:37:54 +07:00
Ayberk A. cdd656bd48 Part of #1736: two toolbar columns run on without group labels, and the drawer stays under the title bar
- Without group labels, the double-column toolbar fills every row: a
  group no longer starts a half-empty row, as the reference's Basic
  toolbar runs on in two columns. One column keeps its faint dashes, and
  with the labels each group still starts its own row under its name.
- The All Tools drawer, now a tall single column, never goes above the
  toolbar's top: on macOS it ran under the window's buttons. Its scroll
  height is what is left from there to the window's bottom.
2026-10-14 07:37:54 +07:00
Ayberk A. 03c0b8f355 Part of #1736: the toolbar without group labels, and the All Tools drawer's groups, flyout menu and Show section
- Preferences › User Interface › Show Tool Group Labels is off as it
  ships: the toolbar sets its groups apart with a faint dash, as the
  Basic and Advanced toolbars of the public toolbar reference do (the
  categories there are the Getting Started toolbar's). It no longer cuts
  "Navigate" to "Navi...". The labels are a preference away.
- The All Tools drawer lists the tools one per row (icon, full name,
  shortcut on the right), each flyout group framed together under the
  heading most of its tools have (the Pencil's group, led by the Shaper,
  under Draw). Tools already in the toolbar stay dimmed; dragging and
  picking are as before.
- Its ☰ menu has Reset and the Basic and Advanced toolbars, the drawer's
  flyout menu of the reference.
- Its Show section shows or hides the controls under the toolbar's tools
  (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the reference
  describes; `toolbar.show {control?, on?}`, saved with the preferences
  (`ui.toolbar_show`, all on for older ones). Edit Toolbar's ••• stays.
- New strings in every catalog.

Reference: https://helpx.adobe.com/illustrator/using/tools.html (the look
is VectorCraft's own, with its own icons).
2026-10-14 07:37:54 +07:00
bflatastic 0934f97627 Fix #1629: an action clicked in the Actions panel stays selected, so Play and Delete act on it 2026-10-14 07:37:53 +07:00
bflatastic c10da25ce4 Importers: nesting is capped, so a deeply nested file can't overflow the stack 2026-10-14 07:37:52 +07:00
bflatastic 9c91c36e7e Docs: Image Trace Shapes rework, stroke caps, enhanced presets and gradients over noise (#1845 to #1848) 2026-10-14 07:19:49 +07:00
BFlat 8ab574b7de Merge pull request #1848 from storytold/u/trace-gradients2
Image Trace: gradients grown over noise and soft rims
2026-10-14 07:18:39 +07:00
bflatastic e0ba6e77d1 Image Trace: gradients grown over noise and soft rims
Gradients (off unless asked for) found a blurred or noisy linear gradient in
part or in pieces, with bands around it.

The areas that may be a gradient are tried largest first, and one that is a
gradient is grown over every pixel next to it whose colour is within 10 levels
of the gradient's there: the pixels noise put a little off, a soft rim, the
pieces noise cut off it.

Linear-gradient inputs with Gradients on, paths beside the gradient, before ->
after: blurred 3.0 -> 0.5 (6 Colors) and 55.5 -> 0.5 (High Fidelity Photo);
noisy 11.0 -> 0.5 and 23.5 -> 1.5; scanned 5.5 -> 3.0 and 46.5 -> 37.0; clean
and aliased as before; dE within 0.02. No gradient on any input that is flat
colour, with any preset (the harness fails if there is one).
2026-10-14 07:14:07 +07:00
BFlat 9c289db91d Merge pull request #1847 from storytold/u/trace-enhanced
Image Trace: enhanced versions of the presets
2026-10-14 07:08:27 +07:00
BFlat b1a5b79d67 Merge pull request #1846 from storytold/u/trace-caps
Image Trace: stroked lines get square caps where their ink is cut square
2026-10-14 07:07:45 +07:00
BFlat 156fbe9912 Merge pull request #1845 from storytold/u/trace-shapes2
Image Trace: Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art
2026-10-14 07:07:15 +07:00
bflatastic 1f4daf519d Image Trace: enhanced versions of the presets
Each documented preset has an enhanced version (`enhanced: true` in
`imageTrace.make`, an Enhanced checkbox under the panel's preset): the preset
with Auto Grouping, and with Shapes in all but High Fidelity Photo. Gradients
stay an option. Flat Logo, a preset of our own, has none. The legacy presets,
and documents traced with them, are as they were.

So that an enhanced preset is no worse than its preset on that preset's inputs:
- A hole that one colour fills is the same shape as the path that fills it, so
  the two still share one outline (a shape beside a fitted hole left slivers).
- A rectangle is as large as where its sides lie on average, not their farthest
  points.
- Auto Grouping keeps the paths of a group in the order they were traced in. It
  put a path first and what is inside it on top: a white counter traced under
  its black letter was then painted over the letter's edge, a light seam
  (Default with Auto Grouping, edge dE 10.24 -> 9.88; legacy 10.05).

The harness measures every enhanced preset beside its preset.
2026-10-14 06:43:57 +07:00
bflatastic 638d55983f Image Trace: stroked lines get square caps where their ink is cut square
Create: Strokes drew every line with round caps: a square-ended line 10 px wide
had its corners 2.94 px outside the stroke.

- A line's ends are square when its ink reaches as far a third of the width to
  either side of its middle as along it (a round end stops short there by a
  seventh of the width); told from lines 3 px wide and more, all the ends of a
  line together. The stroke then has square caps (`TracedPath::square_caps`).
- The last points of a wide line's skeleton can turn towards a corner of its
  end. An end that has left the line the skeleton followed a width back (by a
  quarter of the width, 1 px at least) is put back on it; and an end may be
  pulled back as well as pushed out to where the ink ends.

Outline on the thin-line inputs: largest distance from the art 2.94 -> 0.88 px
(clean 2.94 -> 0.41; aliased 0.88 -> 0.81), mean 0.055 -> 0.050 px, anchors
14 -> 10.
2026-10-14 06:43:57 +07:00
bflatastic c6d9822f26 Image Trace: Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art
- A sharp corner may fall up to 0.75 px short within 3 px of where it should be:
  anti-aliasing and blur take that much off a corner (not on a thin bar, which
  is all corners).
- A rounded rectangle gets the radius that fits its outline best, searched
  around the one its area says, instead of that one alone.
- A rectangle's direction is tried along its longest straight side, the longer
  axis of its area and the pixel grid: a blurred one may have no straight side.
- A regular polygon's sides, corners and size are read from the outline's shape
  (how its distance from the middle goes round, and its area), not from how many
  anchors the fitter gave it.
- An outline without anti-aliasing is known to half a pixel: it gets half as
  much room again.

Plain shapes found with Shapes on, all sizes and variants: Default 57% -> 98%,
6 Colors 62% -> 100%. The harness counts false shapes: none on any input but one
thin line (a rectangle 2 px wide) whose trace is 0.70 px off with or without
Shapes.
2026-10-14 06:43:57 +07:00
BFlat fe83800ed5 Merge pull request #1823 from dotCooCoo/feat/hidden-characters-kept-layout
Type: Show Hidden Characters keeps each text object's marks between frames
2026-10-14 06:15:01 +07:00
BFlat 567f5edaf3 Merge pull request #1819 from dotCooCoo/feat/rotate-view-keeps-old-texture
Canvas: the last frame turns with the view until the frame at the new angle is ready
2026-10-14 06:14:35 +07:00
BFlat 0a6a047fd9 Merge pull request #1817 from dotCooCoo/feat/translucent-type-layout-kept
Render: type with transparency, a blend mode or raster effects keeps its layout between frames
2026-10-14 06:14:20 +07:00
BFlat 534f00dfd3 Merge pull request #1820 from dotCooCoo/feat/font-alias-lookup-memo
Fonts: a missing font's name is looked up once until fonts are added or rescanned
2026-10-14 06:13:59 +07:00
BFlat c5b2495291 Merge pull request #1825 from DWAA1660/fix/1729-verified-pngsuite
Verify pinned PngSuite downloads before publishing corpus files
2026-10-14 06:13:36 +07:00
BFlat 7dd72dee58 Merge pull request #1815 from DWAA1660/fix/1709-control-event-budget
Bound synthetic control input and per-frame request processing
2026-10-14 06:13:09 +07:00
BFlat d6b63fbc87 Merge pull request #1837 from storytold/docs/progress-1014a
Docs: ROADMAP 2026-10-14 adds #1558–#1808 and Image Trace #1647, #1657; guide position in the Transform panel is a convenience
2026-10-14 06:12:40 +07:00
dotCooCoo fb905ae911 Type: Show Hidden Characters keeps each text object's marks between frames
With Type › Show Hidden Characters on, the canvas laid out every
visible text object in view in every frame to place the dots on
spaces and the ¶ and # marks. With 1,000 two-line labels in view,
drawing the marks took a median of 19.7 ms per frame in a release
build.

The overlay now keeps each text object's marks in document space,
together with the node it laid them out from. In a frame it lays out
a text object only when its node is a new `Arc` (an edit makes one)
or the frame before did not draw it, and it lays out all type in view
again when the fonts or the hyphenation exceptions change. The marks
are still placed for the current view in every frame. With the same
labels, drawing the marks takes 0.8 ms per frame, and the shapes drawn
are the same as before.

Docs: docs/target-app-parity.md.
2026-10-14 06:01:10 +07:00
bflatastic 841ea1716d Canvas: the art texture's quads and the transparency grid share one textured-quad helper 2026-10-14 06:01:10 +07:00
dotCooCoo 7975b07b22 Render: type with transparency, a blend mode or raster effects keeps its layout between frames
Only type at full opacity, in Normal blending and without object
effects took its glyph outlines from the geometry the renderer keeps
for each type object. Type at less than 100% opacity or in a blend
mode, and the art drawn for type with a drop shadow, glow, blur or
other raster effect, were laid out again in every frame.

draw_text now takes the geometry from text_geom_of when the type it
draws is fx_owner, the node draw_arc is drawing, and the frame does
not snap type to pixels. While the effect-art cache keeps type art,
that art is drawn as fx_owner. The geometry is laid out again when the
type is edited or the fonts change, and frames with Type Optimized
anti-aliasing lay type out as before. On 8 threads at 2880 x 1800, a
page of 1,000 type objects at 80% opacity takes 13 ms a frame instead
of 23 ms, with the same pixels.

Opaque type kept the layout of earlier frames when a symbol it shows
as an inline graphic changed size and the document's inline graphics
had not been resolved again. text_geom_of now lays type out without
the cache when the text it is given is not the node's own, such as the
copy that Document::inline_resolved makes with the new size.

Docs: docs/target-app-parity.md.
2026-10-14 06:01:10 +07:00
dotCooCoo a1a27636e1 Fonts: a missing font's name is looked up once until fonts are added or rescanned
For a name that is neither a loaded nor an installed family,
FontDb::canonical built the list of names of every loaded face on
every call. Layout asks for a style's face for each span it shapes
and three more times per paragraph, so the time to lay out type set
in a font that is not installed grew with the number of fonts loaded:
a story of 1,000 paragraphs took a median of 409 to 507 ms in a
release build with 400 fonts added.

The database now keeps canonical's answer for each such name and style
until fonts are added or rescanned, and it keeps at most 4,096
answers. The same story takes a median of 56 to 71 ms, and canonical
gives the same answers as before.

Docs: docs/target-app-parity.md.
2026-10-14 06:01:10 +07:00
bflatastic 33a5f2a801 Docs: the roadmap lists the interaction pass and the hardening milestones once each 2026-10-14 06:01:10 +07:00
DWAA1660 fdf6ba9c9d M1: bound synthetic control input expansion (#1709) 2026-10-14 06:01:10 +07:00
bflatastic f17ecf4f5c Docs: ROADMAP 2026-10-14 adds #1558–#1808 and Image Trace #1647, #1657; guide position in the Transform panel is a convenience, not a parity claim 2026-10-14 06:01:10 +07:00
dotCooCoo b3ac169f00 Canvas: the last frame turns with the view until the frame at the new angle is ready
When the last frame took more than 8 ms to render, the desktop app's
canvas sends the next frame to the render worker and draws the last
one until the new one arrives. After a pan or a zoom it draws the last
frame moved and scaled. After the view angle changed, it drew no art
until a frame at the new angle arrived. The angle changes during a
Rotate View drag and when the status bar's angle menu, Reset Rotate
View or a saved view sets it. In a headless run of the canvas on a
document of 50,000 paths, none of the 60 frames of a turn from 0° to
90° drew any art.

The canvas now draws the last frame as a mesh turned into the new
view. Each corner goes where the new view puts the document point that
the corner showed. The frame at the new angle replaces it when it
arrives, as before. Pixel Preview's frames already turned with the
view and are drawn as before.

Docs: docs/target-app-parity.md.
2026-10-14 06:01:10 +07:00
DWAA1660 dfb0ce36bd M14: verify and bound PngSuite corpus downloads (#1729) 2026-10-14 06:01:10 +07:00
BFlat dbd2497b82 Merge pull request #1632 from sanyamsingh001/codex/r61-screens-tiff
G13: offer TIFF in Export for Screens and Asset Export
2026-10-14 05:42:41 +07:00
BFlat f9bdad7ee6 Merge pull request #1789 from osamaamin2011/dxf-centerlines
DXF export: strokes as zero-width centerlines, a 3ds Max preset, and one VPORT view direction
2026-10-14 05:42:21 +07:00
BFlat 1a7ad48633 Merge pull request #1799 from switch87/fix/1557-css-not
SVG import: style sheet rules with :not() selectors apply (#1557)
2026-10-14 05:42:02 +07:00
BFlat 32950368b3 Merge pull request #1631 from sanyamsingh001/codex/r61-artboard-delete
G1: keep Artboard deletion consistent across controls
2026-10-14 05:41:46 +07:00
BFlat a55453d89c Merge pull request #1634 from sanyamsingh001/codex/r61-style-range-fix
G4: capture character styles from selected text
2026-10-14 05:41:28 +07:00
BFlat 8d2aef8cd9 Merge pull request #1633 from sanyamsingh001/codex/r61-style-auto-leading
G4: propagate character-style Auto-leading resets
2026-10-14 05:40:46 +07:00
BFlat e1c0d92a5e Merge pull request #1784 from sanyamsingh001/codex/r62-paragraph-alignment
G4: show mixed paragraph alignment without a selected button
2026-10-14 05:40:20 +07:00
BFlat 316611374e Merge pull request #1782 from sanyamsingh001/codex/r62-guide-alt
G1: switch new ruler-guide orientation with Alt
2026-10-14 05:39:41 +07:00
BFlat db0c8e93f0 Merge pull request #1814 from sanyamsingh001/codex/r62-width-create
G1: create width points through Width Point Edit
2026-10-14 05:39:24 +07:00
BFlat 5ce38c7b2a Merge pull request #1630 from sanyamsingh001/codex/r61-width-shift
G1: honor Shift-double-click in Width Point Edit
2026-10-14 05:39:08 +07:00
BFlat a23f5711af Merge pull request #1809 from Messhias/feat/394-identify-objects-by
Part of #394: Identify Objects By (Object Name / XML ID)
2026-10-14 05:38:49 +07:00
BFlat 327170a8e8 Merge pull request #1750 from Messhias/feat/394-black-output
Part of #394: Appearance of Black › Printing / Exporting
2026-10-14 05:38:34 +07:00
BFlat 216d82a3fd Merge pull request #1682 from Gamedirection/feat/corner-radius-fields
Corner Radius: a field per corner of a live rectangle, with a link toggle
2026-10-14 05:38:16 +07:00
BFlat 449b0d4056 Merge pull request #1670 from AyberkA0/feat/3d-documented-lighting
3D and Materials: the light takes the documented settings
2026-10-14 05:37:56 +07:00
BFlat 646abb3c5c Merge pull request #1669 from AyberkA0/feat/3d-perspective-angle
3D and Materials: Perspective runs 0° to 160°, as documented
2026-10-14 05:37:24 +07:00
bflatastic e2b9705770 Docs: merge rows that two changes edited 2026-10-14 05:16:26 +07:00
sanyamsingh001andCodex cfec15d8d9 G13: offer TIFF in Export for Screens and Asset Export
Co-authored-by: Codex <[email protected]>
2026-10-14 05:16:22 +07:00
bflatastic a57aea8e94 DXF export: leave out the centerline option and the 3ds Max preset for now
The VPORT view direction fix and its test stay. The Export Strokes as
Centerlines option and the preset button are not in the documented DXF/DWG
export options, so they wait for the owner's decision.
2026-10-14 05:16:15 +07:00
Osama Amin 4a68f84282 DXF export: strokes as zero-width centerlines, a 3ds Max R12 linework preset, and one view direction in VPORT 2026-10-14 05:16:09 +07:00
bflatastic 301a99d91c SVG import: nested :not() selectors are bounded 2026-10-14 05:16:03 +07:00
Gert Pellin 6f9595542d SVG import: no slice indexing when writing a new style attribute 2026-10-14 05:16:00 +07:00
Gert Pellin 5f8c4e9ab5 SVG import: style sheet rules with :not() selectors apply (#1557)
usvg's style sheets (simplecss) drop rules whose selector has a :not(), so
rect:not(.keep) { fill: green } left the rect as it was. The text pass's own
cascade now reads :not() of a list of compound selectors (counted as its most
specific argument), and the import pre-pass writes what such rules set on an
element into its style attribute, with the value that wins the cascade there,
so usvg reads what CSS would: against other rules, the style attribute and
!important, inherited from groups, display: none and CSS transforms included.
Selector lists are split at top-level commas only.
2026-10-14 05:15:59 +07:00
sanyamsingh001andCodex bb07d267aa G1: keep Artboard deletion consistent across controls
Co-authored-by: Codex <[email protected]>
2026-10-14 05:15:42 +07:00
sanyamsingh001andCodex 5f4da3beb2 G4: capture character styles from selected text
Co-authored-by: Codex <[email protected]>
2026-10-14 05:15:34 +07:00
sanyamsingh001andCodex 123b8ad8ea G4: propagate character-style Auto-leading resets
Co-authored-by: Codex <[email protected]>
2026-10-14 05:15:30 +07:00
sanyamsingh001andCodex 0c2c98f6e7 G4: show mixed paragraph alignment without a selected button
Co-authored-by: Codex <[email protected]>
2026-10-14 05:15:18 +07:00
sanyamsingh001andCodex ff6aaaaeea G1: switch new ruler-guide orientation with Alt
Co-authored-by: Codex <[email protected]>
2026-10-14 05:15:12 +07:00
sanyamsingh001andCodex 0e6b2e331d G1: create width points through Width Point Edit
Co-authored-by: Codex <[email protected]>
2026-10-14 05:14:20 +07:00
sanyamsingh001andCodex f7714c539f G1: honor Shift-double-click in Width Point Edit
Co-authored-by: Codex <[email protected]>
2026-10-14 05:13:59 +07:00
bflatastic bc50c7fa56 Identify Objects By: rustfmt 2026-10-14 05:13:36 +07:00
Fabio William Conceição 958c1dffe8 Part of #394: Identify Objects By shows and edits Layers names as XML IDs 2026-10-14 05:13:15 +07:00
bflatastic cfa65a2148 Appearance of Black › Printing / Exporting: cover ink-plane documents and CMYK exports
Drops the documented richBlack parameter: commands don't list it.
2026-10-14 05:12:59 +07:00
Fabio William ConceiçãoandCursor b6d14b1fd0 Part of #394: Appearance of Black › Printing / Exporting draws pure CMYK black as RGB black
Co-authored-by: Cursor <[email protected]>
2026-10-14 05:11:55 +07:00
GameDirection b5bc8f0d19 Corner Radius: a field per corner of a live rectangle, with a link toggle
The Corner Radius field in the Properties and Transform panels has a
link toggle behind it (the "singleCornerRadius" preference, on by
default). When the link is on, one field sets every corner, as before.
When it is off, the panels show one field for each corner of a live
rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Each field
sets its own corner through object.setLiveShape {corners, radius}.

This is the part of #1594 that does not change the corner widget drag
or its modifier keys, split out for review.

Tests: a Properties panel test (the link on shows one field; off shows
the four labelled fields with each corner's radius, in line and in
order). The new labels are translated in all 15 catalogs.
2026-10-14 05:11:22 +07:00
Ayberk A. 043b446b94 3D and Materials: the light takes the documented settings
The public 3D and Materials panel options give the Light Settings as
Intensity (0–100 %), Rotation (-180° to 180°), Height (0° to 90°),
Softness (0–100 %) and Ambient Light (0–200 %), with the light moved to
the back or the front of the object. The 3D effects (Revolve included)
had Direction and Elevation (an azimuth and elevation), Intensity and
Ambient (0–100 %).

- Rotation turns the light round the object on the page (our reading:
  0° on its right, counterclockwise); Height raises it from beside the
  object (0°) to straight in front (90°); Light behind the object puts
  it at the back. The light gizmo's ring and line are Rotation and
  Height.
- Softness wraps the light round the object: at 0 % it falls off with
  the angle as before, at 100 % it reaches nothing only straight away.
- Ambient Light runs to 200 %; faces stay no brighter than the art.
- lightRotation, lightHeight, lightBehind and lightSoftness replace
  lightAzimuth and lightElevation. An earlier light is read as the same
  direction, so it shades exactly as before (old and new code give the
  same colours for the four effects and five lights); effect.setParams
  turns a stored one into the new form before applying new values, and
  the parameter description names lightAzimuth and lightElevation as the
  earlier form, so effect.apply and effect.setParams still take them. The
  default light is the earlier default's: Rotation 125.26°, Height 30°.
- The dialog's Lighting tab shows the settings in the documented order;
  Softness, Ambient Light and Light behind the object are in every
  catalog, and the unused Direction, Elevation and Ambient are gone.

Light colour, presets, several lights and shadows are not built.
2026-10-14 05:10:48 +07:00
Ayberk A. 39db6abfce 3D and Materials: Perspective runs 0° to 160°, as documented
The public 3D and Materials panel options give Perspective as 0° to 160°
("how close or far the object appears"); it was a 0–100 % strength, for
Revolve, Extrude & Bevel, Inflate and Rotate alike.

- The eye stands where a sphere about the object's centre, √3 times its
  half size (the box around it fits inside), fills that angle of the
  view: 0° is none, wider brings the eye nearer, and at 160° it is just
  outside the sphere, never inside the object. The documentation doesn't
  say how the angle places the eye; docs/three-d.md says this is our
  reading.
- `perspectiveAngle` (degrees) replaces `perspective` (%). A strength from
  an earlier file or script is read as the angle that shows exactly the
  same view: sin(angle / 2) = √3 × strength / 4, 100 % about 51.3°. An
  angle given too wins. effect.setParams turns a stored strength into the
  angle before applying new values. The parameter description names
  `perspective` as the earlier form, so effect.apply and effect.setParams
  still take it under the key check of #1592. Old and new code draw the four effects identically at 0, 25,
  60 and 100 % (bounds equal to 1e-9).
- The dialogs' Perspective fields run 0–160°.

# Conflicts:
#	docs/target-app-parity.md
2026-10-14 05:10:09 +07:00
BFlat a3357121fb Merge pull request #1792 from dotCooCoo/feat/thread-reflow-copies-only-changed-stories
Type: an edit copies a threaded story only when it changes one of its frames
2026-10-14 05:07:09 +07:00
BFlat 81d9e37e8f Merge pull request #1767 from dotCooCoo/feat/open-resolves-links-off-ui-thread
Open: the opening thread also reads linked files and lays out type
2026-10-14 05:06:41 +07:00
BFlat bb70aa8359 Merge pull request #1744 from dotCooCoo/feat/symbol-instance-art-bounds
Symbols: instances of imported symbols are measured by their art
2026-10-14 05:06:05 +07:00
BFlat 9258c4ea57 Merge pull request #1690 from dotCooCoo/feat/svg-style-rule-index
SVG import: the style cascade looks up each element's rules by id, class and type
2026-10-14 05:05:34 +07:00
BFlat b0ec580828 Merge pull request #1755 from dotCooCoo/feat/cmyk-transparency-planes
Render: the ink planes of CMYK documents with transparency are composed on several threads
2026-10-14 05:04:55 +07:00
BFlat 03e6657e93 Merge pull request #1689 from dotCooCoo/feat/outline-mode-path-caches
Render: Outline view draws paths and clipped art from geometry kept between frames
2026-10-14 05:04:17 +07:00
BFlat aeda9de044 Merge pull request #1688 from dotCooCoo/feat/live-effects-follow-document-edits
Render: live objects are evaluated again when patterns, symbols, fonts or color settings change
2026-10-14 05:03:40 +07:00
BFlat 35ecdf2142 Merge pull request #1687 from dotCooCoo/feat/group-backdrop-region
Render: the canvas no longer draws its picture back for each non-isolated group that blends
2026-10-14 05:02:02 +07:00
BFlat 1b54bc335d Merge pull request #1667 from dotCooCoo/feat/stroke-outlines-per-object
Render: objects that share an appearance keep their own stroke outlines between frames
2026-10-14 05:01:35 +07:00
dotCooCoo 4844d54fda Type: an edit copies a threaded story only when it changes one of its frames
After every edit of a document with threaded text, each frame of a
drag included, threads::reflow copied the runs and the text object of
every frame of every thread, and then dropped the copies for each
thread whose frames the edit had not changed. With a story of 50,000
characters threaded through 8 frames with a style change at every word
(9,618 runs), moving another object took 7.8 ms in a release build,
and a frame of a drag took 7.2 ms.

reflow now copies a thread's story only when one of its frames
changed. The same move takes 0.013 ms, and the drag frame takes
0.012 ms. Threads with a changed frame re-flow as before, and the
documents and undo steps after edits are the same as before.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
dotCooCoo 85cea7efea Open: the opening thread also reads linked files and lays out type
When the desktop app opened a document on a thread of its own, that
thread parsed it, but open_read, which the app ran on the UI thread,
read the document's linked files, decoding BMP, PSD and RGB TIFF files
and storing them again as PNG, and laid out the type. In release builds,
a document with 12 linked 3000 x 2000 TIFFs kept the UI thread busy for
1.04 s, and one with 300 Auto Size area type objects, every second one
in three columns, kept it busy for 0.85 s.

read_for_open now reads the linked files and lays out the type on the
opening thread, with the path and the Update Links preference that
opening::start passes it, and open_read only adds the finished document.
open_read now takes 0.02 ms and 0.1 ms on those documents. For the
measured documents, the opened documents, results and undo states are
the same as on main.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
bflatastic 37174ac306 Render tests: the symbol art bounds test holds the fonts steady (#1744) 2026-10-14 04:56:30 +07:00
dotCooCoo 21a386a6d4 SVG import: the style cascade looks up each element's rules by id, class and type
The SVG import's style cascade tested every <style> rule against each
element it was asked about. The import asks about the objects outside
<defs> and the like when the file contains `display`, about each shape
when the file contains `non-scaling-stroke`, and about each <text>, its
spans and its ancestors. On a file with 2,000 class rules and 100,000
rectangles, `vectorcraft-cli info` took 3.54 s with one `display`
attribute in the file and 2.03 s without it.

The rules are now filed under the id of their selector's rightmost
compound, else under its first class, else under its type. An element
is tested against the rules filed under its id, its classes and its
type, and the rules filed under none of these, in source order. It
matches the same rules in the same order and gets the same
properties. The file with the `display` attribute now takes 2.10 s.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
dotCooCoo f9cbcfb046 Render: the ink planes of CMYK documents with transparency are composed on several threads
A CMYK document that shows transparency is drawn as two ink planes,
and ink::compose turned them into screen colors one pixel at a time on
one thread. At 2880 x 1800 that took about as long as drawing one
plane of 50,000 paths. The renderer also searched a CMYK document for
transparency in every frame.

compose now splits the frame into bands, as many as the renderer has
threads and no more than one per 65,536 pixels. The calling thread
composes the first band and a scoped thread composes each other band,
with the same per-pixel code. The renderer keeps the answer of the
transparency test with the layers, symbol art and pattern art it was
found for, held weakly, and tests again only when the document holds
other art. With Overprint Preview on, a document whose layers or
symbols hold an overprinting fill or stroke is still tested in every
frame. FrameStats::ink_threads reports how many threads composed a
frame. On 8 threads at 2880 x 1800, a page of 300 shapes at 20% to 90%
opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with
one at 99% opacity take 258 ms instead of 361 ms, with the same
pixels.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
dotCooCoo 2ce6f90522 Render: Outline view draws paths and clipped art from geometry kept between frames
In Outline view the renderer made a new path from the path data of
each path and compound path it drew, in every frame. In Preview the
renderer draws plain paths from the path it keeps for each object
between frames. In Outline view the members of a clip group were
culled by bounds measured again in every frame, and the bounds of a
group inside a clip group were measured from every path in it. The
outlined shapes of an Image Trace object were culled and drawn the
same way.

In Outline view the renderer now draws paths and compound paths from
the paths it keeps for them, and culls the members of clip groups and
the outlined shapes of Image Trace objects by the bounds it keeps for
them. The live-effect geometry of those members is kept between frames
too. The frames have the same pixels as before.

With 50,000 paths at 2880x1800 on 8 threads, an Outline frame at 400%
took 47.1 ms on main and takes 16.7 ms when the paths are the members
of one clip group. It took 36.6 ms and takes 26.3 ms when the paths
are paired into compound paths.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
bflatastic caa80fb213 Render tests: the live object tests take the font locks (#1688) 2026-10-14 04:56:30 +07:00
dotCooCoo d9bd9b134d Symbols: instances of imported symbols are measured by their art
Every symbol instance was measured as its transform applied to the
20 × 20 square around the origin, which symbols made in the app keep
their art in. The SVG and DXF importers keep symbol art as drawn, so
the box of an instance of an imported symbol did not match its art.
The SVG Star instance at x 20, y 30 draws from (20, 30) to (40, 50)
and was measured from (10, 20) to (30, 40). Align moved such an
instance by the wrong amount, clicks and marquees beside its art
selected it, and the canvas, exports and prints left it out when its
art reached the view or the page and the square did not.

An instance is now measured by its symbol's art, which
Document::symbol_boxes looks up in the document each time
(Node::geometric_bounds_with, visual_bounds_with and
reach_bounds_with). With Use Preview Bounds on, the strokes in the art
count at their own weight, as the canvas draws them, and
object.setBounds counts them at that weight when it sizes an instance
or an object that holds one. When strokes do not scale and a width or
height of 0 or more is smaller than what a path's stroke adds,
object.setBounds now gives the path no width or height, as it does
for an instance whose art has strokes; before, it mirrored the path.
With Use Preview Bounds on, object.setBounds now measures the margins
that strokes add object by object, the members of groups and the
objects in symbol instances' art included, and solves for the width,
height and reference point asked for when the width and height are 0
or more. Before, it measured the margins of the whole selection and
could miss the size or the reference point asked for when strokes did
not scale and the objects' strokes added different margins, and when
strokes scaled and the width and height were out of proportion, even
for one path. A clip group, a Shaper Group, a compound path, type, and
a group or instance with effects or an opacity mask of its own are each
measured as one object, and so is an instance in perspective or one
turned or slanted relative to the bounding box, other than by quarter
turns. When the selection holds more than 4,096 objects to measure this
way, each selected object is measured as one. Instances inside symbol
art are measured by their own symbol's art. An instance is measured as
the square when its symbol is missing, when its symbol or a symbol
nested in its art holds an instance of itself, and when symbols nest 32
deep in its art.

Align and Distribute, the Transform panel, the bounding box and the
selection outline, clicks, the marquee, document.inspect and
document.node, Fit to Artwork Bounds and Fit to Selected Art, canvas
culling, thumbnails, the SVG filter region of an instance with a
raster effect, raster effect images, Export Selection, the SVG of an
artboard, printing, and the PDF, EPS, EMF/WMF and DXF writers use the
new box. The renderer also culls an instance by the effects and
brushed strokes in its art, and it culls a blend, envelope or repeat
by the brush art of what it holds and the effects in the art of the
instances in it. It culls an instance with geometry effects of its own
by the effects in its art after those geometry effects reshape the art.
It measures the effects in a symbol's art once for each symbol and
linear part of the transform that places it. For symbols whose art
holds instances, it measures at most 4,096 placements a frame, and at
most 4,096 more for each pattern tile it rasterizes and each placed
document it draws. Past that, it estimates the reach from the symbol's
reach with no transform, which it measures once. When the drawn
document's symbols change, the renderer drops the cached bounds of the
instances and of the objects that hold them.

The PDF and EPS writers, the SVG of an artboard and Skip Blank
Artboards grow the box of an instance whose art holds brushed strokes
by 16 times the widest stroke (at least 1 pt) of the objects in that
art that have a brushed stroke, plus 8 pt, as the renderer grows the
cull bounds of a brushed path. They keep an instance whose brush art
reaches the page. The SVG of an artboard also keeps an object that
holds an instance when what the canvas paints for it, the effects in
the symbol's art and the instance's own included, reaches the artboard.

Not yet: other commands that measure objects, such as Select › All on
Active Artboard, Transform Each and Convert to Artboards, still measure
an instance by the square.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-14 04:56:30 +07:00
dotCooCoo 78980c8190 Render: the canvas no longer draws its picture back for each non-isolated group that blends
A transparency group whose members blend with the art below it, and a
masked object with a blend mode, are drawn over a copy of that art. For
each such group in each frame, the renderer rendered everything drawn
so far into a new pixmap the size of its context (on the canvas, the
whole view), started that context again and drew that pixmap back into
it as an image, which the next copy and the frame rendered again. At
2880x1800, a document with 50 translucent groups, each holding a
Multiply rectangle, took 299 ms a frame on 8 threads.

Every render that composites in 8 bits, the canvas included, now keeps
that picture and renders what the context draws next over it. The
backdrop is still cut from the picture. The 50 groups take 65 ms a
frame, and the frames have the same pixels. Renders that composite in
floating point (raster exports, Save for Web, Object › Rasterize and
Crop Image) still draw the picture back.

Docs: docs/target-app-parity.md.
2026-10-14 04:56:29 +07:00
bflatastic 819e53fe92 Render tests: the stroke outline tests hold the fonts steady (#1667) 2026-10-14 04:56:29 +07:00
dotCooCoo 6504dc7da2 Render: live objects are evaluated again when patterns, symbols, fonts or color settings change
The renderer kept the evaluation of each blend, envelope and repeat
while the object and a hash stayed the same: the addresses of the art of
the symbols the object showed and of the patterns it painted with, and
the patterns' tile bounds. When anything else the evaluation reads
changed, such as a pattern's tile type, symbols in symbol art, the
fonts or the color settings, a renderer that had drawn the object went
on drawing the old evaluation. The quads of a gradient mesh were kept
the same way after a change to the color settings, and the art drawn
for a live object with an effect of its own was kept by the object's
address alone.

resources_key now also walks the opacity-mask, symbol and pattern art
it finds. It hashes what it finds, the font generation when it finds
type or a placed document, and the color settings. Mesh quads are made
again when the color settings change. The art drawn for an object's
effects is made again when the hash changes, if it was made from
evaluated live objects.

With other builds running, a temporary release test timed the hash for
400 live objects at 17 to 506 microseconds a frame, against 10 to 16
before.

Docs: docs/target-app-parity.md, docs/gaps.md.
2026-10-14 04:56:29 +07:00
dotCooCoo 9f0a9a362b Render: objects that share an appearance keep their own stroke outlines between frames
The renderer kept the outline of a stroke under the address of the
stroke in the object's appearance, the bits of the width and the
tolerance level, and used an entry only when the entry held the object
being drawn. The slices of a gradient along or across a stroke were
kept the same way. Objects can share one appearance until one of them
changes it. Copies made by Duplicate Selection share the original's
appearance, gradients included. For objects without a gradient fill or
stroke, so do the art the renderer places for unstained symbol
instances, the copies a live repeat draws and copies made by
edit.duplicate. In each frame, the objects that share an appearance
replaced each other's entries. The renderer therefore outlined their
strokes again in every frame.

The two caches now key each entry by the address of the object as
well, and each object keeps its own outlines from frame to frame. Art
that the renderer makes again in every frame keeps the old key: symbol
art that placed_art does not keep, and the evaluations of live objects
and the art of objects with effects that their caches do not keep. The
copies in such art replace each other's entries as before.

vectorcraft-cli bench of 2,000 unstained instances of a symbol of
eight paths took 45.7 ms a frame on main and 31.1 ms with this change,
at 2880x1800 on 8 threads, and a grid repeat of the same paths, which
draws 2,000 groups, took 45.0 ms on main and 27.2 ms with this change.
The peak working set of a bench run on the instances went from 88 MiB
to 122 MiB. In the tests, each frame draws what a new renderer draws.
2026-10-14 04:56:29 +07:00
BFlat cd89392ac9 Merge pull request #1739 from KnottyDyes/squad/remote-stale-connection
MCP: reconnect closed control sockets before sending requests
2026-10-14 04:43:14 +07:00
BFlat db1d586674 Merge pull request #1768 from fahrim/fix/1728-jsonrpc-version-and-reply-id
Fix #1728: require the JSON-RPC 2.0 envelope and correlate control replies
2026-10-14 04:42:45 +07:00
BFlat 7543ef966c Merge pull request #1764 from fahrim/fix/1727-framing-limits
Fix #1727: cap MCP framing input, batches and kept command_batch results
2026-10-14 04:42:21 +07:00
BFlat 67f131d786 Merge pull request #1797 from DWAA1660/fix/1726-control-deadlines
Fix control sockets retaining connection slots indefinitely
2026-10-14 04:41:56 +07:00
bflatastic 2366fcfd80 docs/mcp.md: the server reconnects to the app before a call when the connection was closed 2026-10-14 04:31:44 +07:00
bflatastic 7e56066847 MCP: property test for the framing, batch and envelope limits; the refusal without an id is documented (#1727, #1728) 2026-10-14 04:31:44 +07:00
bflatastic 63b0dc4c32 MCP: a connection holding bytes nobody asked for is replaced before the request is sent 2026-10-14 04:31:44 +07:00
bflatastic d3327b17a1 MCP: a control refusal without an id reports the app's reason instead of an id mismatch (#1728) 2026-10-14 04:31:44 +07:00
KnottyDyesandClaude Opus 5.5 16366ce422 MCP: reconnect closed control sockets before sending requests
After the app restarted, the remote backend wrote the next request to
the old, closed connection and reported that the operation may have
completed, although the restarted app received nothing. Reused
connections are now checked without blocking before writing, and a
closed one is replaced first. If the app can't be reached, the error
says so and that nothing was sent. Failures after the request was
written still report uncertainty and are never replayed.

Loopback tests cover a restart between calls (the edit is applied
exactly once) and a refused reconnect. The lost-reply test from #1246
still passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo
2026-10-14 04:31:44 +07:00
Fahri Meral cc518797a8 Fix #1728: require the JSON-RPC 2.0 envelope and correlate control replies
The server dispatched requests and notifications without ever checking jsonrpc, so a missing or wrong version could invoke tools or flip notification state; Remote accepted any reply with ok/result/error without comparing ids, so a faulty endpoint could attribute a stray result to the current call. handle() now requires jsonrpc 2.0 before dispatch (other envelopes get -32600, or are ignored for notifications, which have no id for a reply) and Remote::call rejects a reply whose id doesn't echo the request's, dropping the connection so the next call starts fresh, with the usual uncertainty error. Three new tests cover wrong/missing versions (incl. batch entries and a state-changing notification) and the mismatch-reconnect; docs/mcp.md documents both. cargo test -p vectorcraft-mcp passes.
2026-10-14 04:31:44 +07:00
bflatastic 5336d80196 command_batch: the step whose result passes the size cap says that it ran (#1727) 2026-10-14 04:31:43 +07:00
bflatastic 85c81ff1e9 Control: an idle connection is closed without a line, so no error waits where a client's next reply would be (#1726) 2026-10-14 04:31:43 +07:00
Fahri Meral b75ced8a70 Fix #1727: cap MCP framing input, batches and kept command_batch results
The server read stdio lines and remote replies without any byte bound, answered batches of any size, and command_batch kept every step result: one faulty peer or batch could allocate and work without bound. Request lines are now read in bounded chunks (16 MiB; longer lines get a -32600 reply, are skipped, and framing continues), protocol batches hold at most 64 messages, control-channel replies past 256 MiB drop the connection with the usual uncertainty error, command_batch takes at most 256 steps and keeps at most 8 MiB of step results (past that the batch stops with the reason, still closing its atomic undo group). Eight new tests cover the line/batch/step/result caps and the atomic-group close; docs/mcp.md and the command_batch tool text document them. cargo test -p vectorcraft-mcp passes.
2026-10-14 04:31:43 +07:00
DWAA1660 a0bd1fd16b M1: bound control connection transport waits
Fix #1726 with idle, absolute line and reply-write deadlines and connection-slot guards. Add real-loopback regressions and document reconnect behavior.

Validated with cargo xtask ci: all seven gates passed; 6156 tests passed, zero failed, seven existing ignored.
2026-10-14 04:31:43 +07:00
BFlat f8396dbc4f Merge pull request #1663 from ham9zah/pen-drag-threshold
Pen: drag threshold, Precise Cursors command and tests
2026-10-14 04:28:59 +07:00
BFlat fe027037d2 Merge pull request #1795 from danngreen/fix/render-tests-font-race
Fix race over font database in tests
2026-10-14 04:28:44 +07:00
BFlat 1369bd0c93 Merge pull request #1785 from sanyamsingh001/codex/r62-import-fuzz
CI: isolate text import fuzzing from installed fonts
2026-10-14 04:28:26 +07:00
BFlat c2a944a686 Merge pull request #1677 from marcoarnulfo/fix/color-picker-remembers-mode
Color panel: the mode picked last comes back after a restart
2026-10-14 04:28:10 +07:00
BFlat e02ffe9513 Merge pull request #1752 from switch87/fix/1748-point-type-align
Fix #1748: text.create takes justify for point type alignment
2026-10-14 04:27:52 +07:00
BFlat a06899fdd0 Merge pull request #1746 from marcoarnulfo/fix/select-same-type-paint
Select > Same: type matches on its characters' fill and stroke
2026-10-14 04:27:35 +07:00
BFlat 422a5d7eb9 Merge pull request #1765 from AyberkA0/fix/1756-expand-repeat
Fix #1756: Object → Expand expands repeats
2026-10-14 04:27:19 +07:00
BFlat 5fb311fb60 Merge pull request #1808 from AyberkA0/fix/1662-ui-scale-slider-medium-dark
Fix #1662: slider rails show on Medium Dark
2026-10-14 04:27:03 +07:00
BFlat 1a506fae4c Merge pull request #1798 from AyberkA0/fix/1757-palette-arrows
Fix #1757: arrow keys move through the command search results
2026-10-14 04:26:46 +07:00
BFlat 3298c5088c Merge pull request #1734 from AyberkA0/fix/1661-paintbrush-brackets
Part of #1661: [ and ] reach the tools that take them
2026-10-14 04:26:29 +07:00
BFlat 951bf42714 Merge pull request #1804 from AyberkA0/fix/1679-pencil-keeps-appearance
Fix #1679: a filled pencil path keeps a stroke of None
2026-10-14 04:25:57 +07:00
BFlat 7d5c4b4b9d Merge pull request #1802 from AyberkA0/fix/1686-esc-text-edit
Fix #1686: Escape in type editing goes to the Selection tool
2026-10-14 04:25:41 +07:00
BFlat 6187a3f462 Merge pull request #1803 from AyberkA0/fix/1743-esc-cancels-point-drag
Fix #1743: Escape cancels a Direct Selection drag
2026-10-14 04:25:22 +07:00
BFlat 589797adef Merge pull request #1666 from turtwig/main
Direct Selection stays inside isolation mode
2026-10-14 04:25:03 +07:00
BFlat 043ea1d525 Merge pull request #1674 from turtwig/fix/delete-selected-anchors
Delete removes every selected anchor and keeps the remainder selected
2026-10-14 04:24:46 +07:00
BFlat a1eb4fc090 Merge pull request #1791 from AyberkA0/fix/1758-cursor-hotspot
Fix #1758: keep the custom title bar window borderless on Windows
2026-10-14 04:24:20 +07:00
ham9zahandClaude Sonnet 5.5 cde7b0ece7 Pen: Space pressed inside the drag threshold moves the anchor by the next move only
The drag threshold kept `last` at the press position until it was crossed, so
Space held when the move that crossed it came moved the anchor by the whole
distance since the press. `last` now follows the pointer while the threshold
swallows the moves, and the threshold is measured from a separate `pressed`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 03:55:41 +07:00
Dan Green 96d5a14738 Render tests: tests that add fonts no longer run alongside the frame cache tests
tests_fontchange adds fonts to the shared font database, which makes every renderer lay type
out again; a tests_memory test counting the type it kept between frames then failed when one ran
alongside it (about half of the runs of the render tests). The font tests now hold a lock alone,
and the frame cache tests hold it shared.
2026-10-14 03:55:41 +07:00
sanyamsingh001andCodex 4944facd0a CI: isolate text import fuzzing from installed fonts
Co-authored-by: Codex <[email protected]>
2026-10-14 03:55:41 +07:00
Marco Arnulfo e11867128e Color panel: the mode picked last comes back after a restart (fixes #1675) 2026-10-14 03:55:41 +07:00
Gert Pellin ed1e976264 Fix #1748: text.create takes justify, so point type can be centred on its point
text.create, text.createInPath and the MCP add_text tool now take a
`justify` (auto|left|center|right|justifyAll, as text.setStyle). Point
type already laid out centred or right-aligned text around its origin;
it just could not be made that way, so scripts measured the bounds and
moved the text. Changing it afterwards stays text.setStyle {justify}.
2026-10-14 03:55:41 +07:00
Marco Arnulfo d9328c7668 Select > Same: type matches on its characters' fill and stroke (fixes #1737) 2026-10-14 03:55:41 +07:00
Ayberk A. 2f4cc8a1cc Fix #1756: Object → Expand expands repeats
Expand had nothing for live repeats, so a radial, grid or mirror repeat stayed
live. Its Object option now turns every repeat in the selection (inside groups
too, inner ones first) into a group of its instances, keeping the repeat's id
and place, in the same undo step; the dialog enables Object for a repeat.
Expand Repeat shares the code.
2026-10-14 03:55:41 +07:00
Ayberk A. 3024821c32 Fix #1662: slider rails show on Medium Dark
The rail used the fields' border, which on Medium Dark (the default) is all but
the panel's colour; it now uses the buttons' border, which stands out on every
brightness.
2026-10-14 03:55:41 +07:00
Ayberk A. 5fc0be5b85 Part of #1757: the palette test's frame closure needs no mut 2026-10-14 03:55:41 +07:00
bflatastic 3152436020 Brackets: keep to the tools whose size the keys are documented for
The typed [ and ] now reach the Blob Brush, the Eraser and the Symbolism
tools, whose bracket keys are in the public shortcut lists. A size of the
Paintbrush's own (and path.freehand's scale) is left out until its
behaviour is decided: #1661 stays open for that.
2026-10-14 03:55:41 +07:00
ham9zahandClaude Sonnet 5.5 66a0f457e9 Add a Precise Cursors command and fix the misleading Caps Lock comment
General > Use Precise Cursors already worked and is saved with the other
preferences. This adds a `view.preciseCursors` command that toggles that same
preference, so it can be given a shortcut in Edit > Keyboard Shortcuts. It has
no default shortcut and no View menu item.

The comment in canvas.rs claimed Caps Lock gave precise cursors; nothing reads
Caps Lock (egui has no state for it), so it now describes what the code does.

Tests: for the tools whose own pointer the preference replaces (Pen, the anchor
tools, Curvature, Mesh, Eyedropper, Slice, Blend, Shape Builder) the pointer is
their own when off and a crosshair when on; every state of the Pen's pointer
turns into a crosshair; the preference round-trips through
to_json/apply_prefs; the command toggles it, updates the saved UI state, is
listed in the shortcut editor without a key and can be assigned one; switching
it creates no undo step and leaves the document saved; with system cursors the
glyph fallback is the system crosshair either way. The new label is translated
in the languages that claim complete menus.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 03:55:41 +07:00
Ayberk A. de1ede49a7 Fix #1757: arrow keys move through the command search results
Up and Down move the highlighted result (wrapping at the ends), the pointer
moving over a result highlights it, Enter runs the highlighted one, and a new
query starts from the first again.
2026-10-14 03:55:41 +07:00
Ayberk A. 3f1e72d983 Fix #1661: [ and ] size the Paintbrush, and reach the tools that take them
The tools took `[` and `]` (the Blob Brush's and the Eraser's size, the
Symbolism tools' diameter and, with Shift, intensity), but the app never
passed the keys on: only `ui.key` over MCP reached them.

- The keyboard handler gives a typed `[`, `]`, `{` or `}` (Shift) to the
  active tool when it claims the key. It reads the typed character, so
  layouts where a bracket takes Option or AltGr (Option-8 on a Turkish
  Mac) work too. A field with the keyboard, the Type tool and Cmd/Ctrl
  shortcuts are left as they were.
- The Paintbrush has a size of its own, 10 to start as the Blob Brush's:
  `[` and `]` take a point off or add one, and the next stroke is drawn
  at size / 10 of the stroke weight (path.freehand `scale`). Public
  references describe the keys as changing the size the Paintbrush draws
  with, before drawing, the brush definition left as it is; so the
  stroke weight new art takes stays, and the selected path keeps its
  stroke. Every brush type, as the report and the Tuts+ guide describe;
  the step is our own.
2026-10-14 03:55:41 +07:00
ham9zahandClaude Sonnet 5.5 3ea4802a53 Pen: test that a click after a smooth anchor curves from its side only
Covers the Illustrator behaviour that a corner placed after a smooth anchor
has no handles, so the segment is shaped by the previous outgoing handle alone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 03:55:41 +07:00
ham9zahandClaude Sonnet 5.5 eace7c749b Pen: ignore pointer movement inside a 3 px drag threshold
A press released within a few screen pixels is a click: the anchor stays a
corner with no handles, as in Illustrator, instead of getting near-zero
handles from a trembling hand. The threshold is in screen pixels, so it
does not change with zoom. It applies to new anchors, closing a path and
pulling a handle out of the last anchor.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 03:55:41 +07:00
Ayberk A. ef2d678cbf Fix #1679: a filled pencil path keeps a stroke of None
The Pencil gave every path a black stroke when the stroke was None. With Fill
new pencil strokes on and a fill set, the path now keeps no stroke; only a path
that would show nothing (no fill, no stroke) still gets black.
2026-10-14 03:55:40 +07:00
Ayberk A. 1e60a572b1 Fix #1686: Escape in type editing goes to the Selection tool
Escape still ends editing (an empty new point type is discarded) and the type
stays selected; the Type tool now claims Escape while editing and asks for the
Selection tool, so tool shortcuts work again without the mouse.
2026-10-14 03:55:40 +07:00
Ayberk A. e80cc9b49b Fix #1743: Escape cancels a Direct Selection drag
Escape while dragging anchors, handles, segments, objects, a type area, a blend
spine or a marquee with the Direct (or Group) Selection tool cancels the drag:
what it moved goes back and the release after it does nothing.
2026-10-14 03:55:40 +07:00
turtwig f297589c76 Direct Selection stays inside isolation mode
Marquee, anchor clicks, blend spines, and Group Selection only pick the
isolated object and what is inside it. A regression test covers a path
outside an isolated group.
2026-10-14 03:55:40 +07:00
turtwig 89501d73c2 Delete removes every selected anchor and keeps the remainder selected
A fully selected object is deleted along with partial anchors on other
paths. Several anchors on one closed path are removed by their original
indexes. What remains of a path stays selected, so a second Delete
removes it.
2026-10-14 03:55:40 +07:00
Ayberk A. cb9fa7042f Fix #1758: keep the custom title bar window borderless on Windows
winit 0.30 gives an undecorated window WS_CAPTION | WS_BORDER; on some Windows
systems the compositor still reserves that frame, so the content is drawn
offset while the pointer isn't, and the cursor no longer points at what is under
its tip. Strip the two styles once and again each frame (winit restores them on
maximize/restore), as PhotoCraft does (storytold/photocraft#2401).
2026-10-14 03:55:40 +07:00
BFlat bb8897a2eb Merge pull request #1787 from MarekSpodBiedry/issue-1637
Fix numeric fields reverting a typed value when clicking away instead of pressing Enter (#1637)
2026-10-14 03:53:08 +07:00
BFlat 4d54fa27e3 Merge pull request #1773 from MarekSpodBiedry/issue-1722
Linked files: a change during a drag is applied after the drag, not lost (#1722)
2026-10-14 03:52:41 +07:00
BFlat 7f93a191c7 Merge pull request #1775 from MarekSpodBiedry/issue-1720
Do not journal a failed live preview at commit (#1720)
2026-10-14 03:52:17 +07:00
BFlat 0f0380b827 Merge pull request #1774 from MarekSpodBiedry/issue-1721
Stamp File Info dates only after a save succeeds (#1721)
2026-10-14 03:51:47 +07:00
BFlat ae6d70ae19 Merge pull request #1781 from MarekSpodBiedry/issue-1702
Search each shared name tree node once when finding the editing file (#1702)
2026-10-14 03:51:18 +07:00
BFlat b72ca300ed Merge pull request #1779 from MarekSpodBiedry/issue-1708
Cap Liquify dabs while expanding a segment, not only before it (#1708)
2026-10-14 03:51:00 +07:00
BFlat 4892384168 Merge pull request #1780 from MarekSpodBiedry/issue-1707
Fix scatter X axis ticks looping forever on data near the f64 maximum (#1707)
2026-10-14 03:49:57 +07:00
BFlat 64f875ff34 Merge pull request #1776 from MarekSpodBiedry/issue-1716
Check embedded PNG/JPEG bitmap size from the header before decoding (#1716)
2026-10-14 03:49:29 +07:00
BFlat 4d0f1dd0f8 Merge pull request #1783 from MarekSpodBiedry/issue-1698
Fix #1698: entries read from memory obey the size limits too, not only streamed ones (#1698)
2026-10-14 03:49:09 +07:00
BFlat 1e6c17c54b Merge pull request #1778 from MarekSpodBiedry/issue-1712
Create the staging file owner-only when replacing a document (#1712)
2026-10-14 03:48:51 +07:00
BFlat 6f56ad2e7f Merge pull request #1777 from MarekSpodBiedry/issue-1713
Keep Export for Screens suffix inside the export folder (#1713)
2026-10-14 03:48:36 +07:00
BFlat b374fee789 Merge pull request #1786 from MarekSpodBiedry/issue-1696
Refuse to package over the source document or its linked files (#1696)
2026-10-14 03:47:20 +07:00
BFlat 9abdbb0a71 Merge pull request #1807 from AyberkA0/fix/1710-ruzstd-0.9.1
Fix #1710: ruzstd 0.9.1 (RUSTSEC-2026-0344)
2026-10-14 03:47:05 +07:00
bflatastic d775549258 A press outside a field commits what was typed before the press acts 2026-10-14 03:17:55 +07:00
bflatastic 8a7e36ede8 Background save edited meanwhile: undoing back to the saved state is unmodified (#1721) 2026-10-14 03:17:51 +07:00
bflatastic 7abc1cfa10 Scatter overflow test: data that far off the plot is refused, not drawn (#1707) 2026-10-14 03:17:49 +07:00
bflatastic d82d6e3620 Test that a hostile Export for Screens suffix or prefix stays in the export folder (#1713) 2026-10-14 03:17:47 +07:00
BFlat 7fc8173253 Merge pull request #1684 from dotCooCoo/feat/mcp-document-reads-faster
Control: replies are buffered, so a 17 MB reply is sent in 0.09 s instead of 34 s on Windows
2026-10-14 03:08:58 +07:00
BFlat 466b4b0180 Merge pull request #1652 from marcoarnulfo/fix/canvas-cursor-wayland-scale
Cursors: paint the tool cursors on Wayland, where the compositor showed the bitmap twice too big
2026-10-14 03:08:35 +07:00
BFlat 65ec1842ac Merge pull request #1654 from switch87/fix/1653-eps-hard-stops
EPS import: a stitched shading keeps its hard colour changes
2026-10-14 03:04:15 +07:00
BFlat ad358fb4e4 Merge pull request #1651 from switch87/fix/1648-scribble-regions
Scribble: one zig-zag per region, no lines across holes and notches
2026-10-14 03:03:54 +07:00
BFlat 0814b70fe0 Merge pull request #1646 from switch87/fix/1643-roughen-cusps
Roughen (Smooth): a turn-back stays smooth instead of becoming a cusp
2026-10-14 03:03:34 +07:00
BFlat 14df0db488 Merge pull request #1642 from marcoarnulfo/fix/shape-modes-on-group
Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroke keeps the Shape Modes enabled
2026-10-14 03:03:12 +07:00
BFlat 70937b59ba Merge pull request #1641 from marcoarnulfo/fix/align-anchors-default-selection
Align: anchor points of one path align to each other, not to the artboard
2026-10-14 03:02:45 +07:00
dotCooCoo 7fde8739a7 Control: replies are buffered, so a 17 MB reply is sent in 0.09 s instead of 34 s on Windows
The control server wrote each reply with writeln!(out, "{r}") on the
bare TcpStream. serde_json formats a Value one piece at a time, and
each piece (a brace, a quotation mark, a key, a colon, a number) was
sent as a write of its own: the 17 MB document.inspect reply of a
document with 50,000 objects on one layer was sent in 7,857,200 writes.
On Windows 11, sending that reply to a client over loopback took 34 s,
and sending the 28 MB document.json reply took 57 s.
vectorcraft-cli mcp reads every reply from a running app over this
connection.

write_line now writes each reply, each refusal and the "too many
control connections" line through a 64 KiB BufWriter, then flushes
it. The 17 MB reply takes 262 writes. Sending it now takes 0.09 s,
and sending the 28 MB reply takes 0.2 s. The bytes are the same.

Docs: docs/target-app-parity.md.
2026-10-14 02:09:15 +07:00
Marco Arnulfo e50bd58146 Cursors: paint the tool cursors on Wayland, where the compositor showed the bitmap twice too big (fixes #1645) 2026-10-14 02:09:09 +07:00
Gert Pellin 3412d3a073 EPS import: a stitched shading keeps its hard colour changes (#1653)
A stitching function's bound became one stop, evaluated at the bound:
the right piece's colour. Where the pieces don't meet, the left piece's
end colour was lost. The colour just before the bound is now a stop at
the same offset too, ahead of the one after it, when the two differ;
continuous stitches keep one stop per bound as before.

Fixes #1653
2026-10-14 02:09:09 +07:00
Gert Pellin 85335e9e14 Scribble: one zig-zag per region, no lines across holes and notches (#1648)
The rows' spans were all chained into one polyline, so where a row had
more than one span the line ran across the hole between them, outside
the shape. A span now continues the previous row's chain whose
connecting line stays inside the shape (the nearest one); otherwise it
starts a chain of its own. In a convex shape the connector always stays
inside, so the result there is unchanged (render goldens pass).

Fixes #1648
2026-10-14 02:09:09 +07:00
Gert Pellin 977b8fb642 Roughen (Smooth): a turn-back stays smooth instead of becoming a cusp (#1643)
Roughen's jitter can put a point behind its predecessor, so the path
turns back. The Catmull-Rom tangent next - prev then shrinks to almost
nothing and the anchor becomes a cusp, which PDF viewers (MuPDF,
poppler, Ghostscript) draw with a miter spike; the preview doesn't.

catmull_rom now keeps the tangent at least half as long as the longer
chord, and where it is shorter (turns of more than about 150 degrees)
points it along the bisector of the two chords: the turn becomes a round
hairpin. Gentler turns keep the plain Catmull-Rom tangent, so Zig Zag,
Scribble and the render goldens are unchanged.

Fixes #1643
2026-10-14 02:09:08 +07:00
bflatastic f05fbfc292 Pathfinder panel: count a group's shapes without copying them 2026-10-14 02:09:08 +07:00
Marco Arnulfo 7ea75b01ba Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroke keeps the Shape Modes enabled (fixes #1635) 2026-10-14 02:09:07 +07:00
bflatastic 359d9bb833 Distribute spaces the selected anchor points too, and its buttons work with the points of one path (#1636) 2026-10-14 02:09:06 +07:00
Marco Arnulfo dcb963e85b Align: anchor points of one path align to each other, not to the artboard (fixes #1636) 2026-10-14 02:09:06 +07:00
Ayberk A. 0d28ee0872 Fix #1710: ruzstd 0.9.1 (RUSTSEC-2026-0344)
0.9.1 enforces the decompressed size of each block, which 0.9.0 didn't
(memory exhaustion on crafted .ai / Affinity zstd data). The manifests now ask
for at least 0.9.1.
2026-10-13 21:14:00 +03:00
MarekSpodBiedry f4f60b6ac6 Fix numeric fields reverting a typed value when clicking away instead of pressing Enter (#1637) 2026-10-13 18:23:24 +02:00
MarekSpodBiedry 06d3bbd6e7 Search each shared name tree node once when finding the editing file (#1702) 2026-10-13 18:22:14 +02:00
MarekSpodBiedry b50519587d Refuse to package over the source document or its linked files (#1696) 2026-10-13 18:19:12 +02:00
MarekSpodBiedry 4c890b4ba1 Fix #1698: entries read from memory obey the size limits too, not only streamed ones (#1698) 2026-10-13 18:18:52 +02:00
MarekSpodBiedry b8744f6d33 Stamp File Info dates only after a save succeeds (#1721) 2026-10-13 18:18:47 +02:00
MarekSpodBiedry 1f44a18c7a Do not journal a failed live preview at commit (#1720) 2026-10-13 18:17:49 +02:00
MarekSpodBiedry ba94976bf2 Fix scatter X axis ticks looping forever on data near the f64 maximum (#1707) 2026-10-13 18:17:33 +02:00
MarekSpodBiedry 63402451cc Check embedded PNG/JPEG bitmap size from the header before decoding (#1716) 2026-10-13 18:17:05 +02:00
MarekSpodBiedry 65cc818572 Linked files: a change during a drag is applied after the drag, not lost (#1722) 2026-10-13 18:17:02 +02:00
MarekSpodBiedry ce53bb7c57 Cap Liquify dabs while expanding a segment, not only before it (#1708) 2026-10-13 18:16:43 +02:00
MarekSpodBiedry 122fe05cee Create the staging file owner-only when replacing a document (#1712) 2026-10-13 18:16:12 +02:00
MarekSpodBiedry a5186f5a12 Keep Export for Screens suffix inside the export folder (#1713) 2026-10-13 18:16:12 +02:00
BFlat 0d0ea352df Merge pull request #1659 from storytold/fix/1597-guide-position
Fix #1597: the Transform panel shows and sets a selected ruler guide's X or Y
2026-10-13 14:05:51 +07:00
BFlat 206e81c3b7 Merge pull request #1658 from storytold/fix/1602-intersect-snap
Fix #1602: drawn points snap to where paths and guides intersect
2026-10-13 14:05:06 +07:00
BFlat 24afe0f517 Merge pull request #1608 from dotCooCoo/feat/script-checker
Scripts: Check Script… reports what a .jsx script needs and does, without running it
2026-10-13 14:03:59 +07:00
BFlat ec67ed9646 Merge pull request #1603 from switch87/feat/1601-inkscape-pages
SVG import: Inkscape's pages become artboards
2026-10-13 14:03:38 +07:00
BFlat ce558ffb73 Merge pull request #1599 from switch87/fix/1598-filter-warning
SVG import: an unsupported filter's warning names the filter and the object
2026-10-13 14:03:18 +07:00
BFlat cbe7f83881 Merge pull request #1606 from switch87/fix/1604-relative-open-path
Open by a relative path finds relative links; convert reports import warnings
2026-10-13 14:03:03 +07:00
BFlat feb319027e Merge pull request #1611 from switch87/feat/1607-more-opentype-features
Type: stylistic sets, alternates, titling, figure styles, superscript and subscript
2026-10-13 14:02:51 +07:00
BFlat 69e77570b4 Merge pull request #1657 from storytold/u/trace-logo-scans
Image Trace: Flat Logo takes the tones of an unevenly lit colour for one colour
2026-10-13 13:58:19 +07:00
BFlat 4159ffe6fd Merge pull request #1647 from storytold/u/trace-level-bound
Image Trace: levelling moves no end of a line by more than 1.6 px
2026-10-13 13:57:40 +07:00
bflatastic 51e7044b0e Fix #1597: the Transform panel shows and sets a selected ruler guide's X or Y
With only ruler guides selected, X is the position of the vertical ones and Y
that of the horizontal ones; typing a value moves them (guide.move), together
when several are selected.
2026-10-13 13:36:41 +07:00
bflatastic dae13d57f0 Fix #1602: drawn points snap to where paths and guides intersect
A point placed by the Pen, the shape tools and the other drawing tools, and a
dragged anchor, lands where two paths cross, where a path crosses a ruler guide
and where two ruler guides cross, with the Smart Guides label "intersect".
2026-10-13 13:36:41 +07:00
dotCooCoo 3374d0778d Scripts: Check Script… reports what a .jsx script needs and does, without running it
VectorCraft does not run scripts written for the reference app, and
File › Scripts held only the disabled Other Script…. File › Scripts ›
Check Script… picks a .jsx, .jsxinc or .jsxbin file, reads it without
running any of it, and shows a report: what the script needs (the
script language's own objects, the document model, files, menu
commands, dialogs, or features only the reference app has), what it
would do outside the document, the menu-command strings it passes, the
document-model member chains and type names it uses, its directives,
the files it includes, and the problems that would stop it loading.
Copy Report copies the report as JSON. File › Open and drag-and-drop
show the report for those files too.

- vectorcraft-scriptcheck, a new Standalone crate with no
  dependencies, holds the checker: a lexer for ECMAScript 3 with E4X
  literals and the #name and //@name directive lines, and an analysis
  of the tokens that follows variables, function scopes and the files
  a script includes. It never recurses: open brackets, the levels of
  an XML literal and the function bodies being read are kept on stacks
  capped at 4,096. It reads at most 8 MiB of a file and 32 MiB with the
  included files, follows 64 included files 16 deep, looks up at most
  1,024 paths for them, keeps 1,000 entries in a list, tracks 1,000
  variables holding member chains and 1,000 holding File objects, and
  cuts echoed text to 120 characters. A compiled script is reported as
  compiled and isn't read.
- script.check (engine, a query) takes path, data or dataBase64. With a
  path and includes: true, it reads the files the #include lines name
  from inside the script's folder, through the automation read root,
  and when the script or one of those files assigns a member chain or a
  File object to a variable, the check treats the variable as holding
  it in every file. It doesn't follow absolute names, names, folders or
  links that lead out of the folder, names or folders longer than 4,096
  bytes, or JSINCLUDE, and it reads only regular files.
- ui.checkScript shows the report (dialog scriptCheck). File › Open
  lists Scripts (.jsx, .jsxinc, .jsxbin) and sends a script's path to
  script.check, which reads at most 8 MiB of it.

The language rules come from ECMA-262 3rd edition and ECMA-357 2nd
edition. The directive, platform and document-model root names come
from the public JavaScript Tools Guide and scripting guide pages that
catalog.rs cites by URL, and executeMenuCommand, doScript, loadAction,
unloadAction and selectTool come from the public forum posts cited
there; only names are used. Every script in the tests is written from
scratch with invented names.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-13 13:36:41 +07:00
bflatastic 04bb6d6f27 SVG import: an Inkscape page whose box can't be placed is skipped 2026-10-13 13:36:41 +07:00
Gert Pellin ef0ab7ba9d SVG import: Inkscape's pages become artboards
Inkscape 1.2+ saves a multi-page document as <inkscape:page> elements
in its <sodipodi:namedview>; VectorCraft opened it with one artboard
the size of the root, leaving the other pages' art outside any. Each
page with a size now becomes an artboard at its place (through the
viewBox, as the art is), named by its inkscape:label or "Artboard N".
Files without pages keep their one artboard.

Fixes #1601
2026-10-13 13:36:41 +07:00
Gert Pellin 546c81624a SVG import: an unsupported filter's warning names the filter and the object
A filter that can't become a live effect warned "filter on a group
ignored", even on a plain <rect>: usvg wraps every filtered element in
a group of its own, which the message named. The warning now names the
filter and its primitives and the element (by id, or "an object"), and
says it is drawn without it: "filter 'f' (feColorMatrix) on an object
isn't supported; drawn without it".

Fixes #1598
2026-10-13 13:36:40 +07:00
Gert Pellin 13bd24ba79 Open by a relative path finds relative links; convert reports import warnings
document.open (and file.newFromTemplate) with a path relative to the
working directory took the SVG's folder from Path::parent(), which is
"" for a bare file name and was then dropped, so relatively linked
images were "not found" (and, linked by their bare name, not listed in
missingLinks either). A relative path is now made absolute first: its
folder is the working directory, as for the absolute path.

vectorcraft-cli convert printed only the export's warnings; what reading
the input left out now comes as importWarnings.

Fixes #1604
2026-10-13 13:36:40 +07:00
Gert Pellin eb25a85d90 Type: stylistic sets, alternates, titling, figure styles, superscript and subscript
A character style's OpenType features took nine tags. OtFeatures now
also keeps ss01-ss20, salt, titl, pnum, lnum, sups and subs (off by
default, one bit each in `extra`): they are accepted by the type
commands, stored with the style and passed to the shaper, so a font
that has them uses them.

The Affinity import test used ss01 as its example of a feature kept but
not rendered; it now uses cv01 for that (character variants still
aren't rendered) and keeps ss01 as one that is.

Fixes #1607
2026-10-13 13:36:40 +07:00
BFlat 504e719230 Merge pull request #1650 from storytold/fix/1616-flyout-width
Fix #1616: a tool flyout is as wide as its longest translated row
2026-10-13 13:30:10 +07:00
BFlat d443cb031c Merge pull request #1649 from storytold/fix/1620-save-fallback
Part of #1620: a save falls back when a folder refuses the temporary file or the rename
2026-10-13 13:29:57 +07:00
BFlat 41afc9f3e5 Merge pull request #1614 from lewandowskit/i18n-polish-review
i18n(pl): confirm the maintainer-added Polish rows
2026-10-13 13:28:33 +07:00
BFlat 4d62e0d0ef Merge pull request #1622 from dotCooCoo/feat/lock-hide-above-linear
Object: Lock and Hide › All Artwork Above go through each top-level layer once
2026-10-13 13:28:09 +07:00
BFlat 1d1e28a51f Merge pull request #1621 from dotCooCoo/feat/pattern-tiles-per-scale
Render: pattern tiles at different scales are kept between frames
2026-10-13 13:27:53 +07:00
BFlat f870f3ac09 Merge pull request #1617 from marcoarnulfo/fix/area-type-narrow-top-lines
Area type: a word too wide for the top lines of a shape moves down instead of breaking
2026-10-13 13:27:27 +07:00
BFlat 67dcfd4fc4 Merge pull request #1619 from marcoarnulfo/fix/ai-zero-length-dots
Strokes: a zero-length subpath draws its round or projecting cap as a dot
2026-10-13 13:27:11 +07:00
BFlat 972945f80d Merge pull request #1625 from marcoarnulfo/fix/smb-save-fsync
Save: a network drive on macOS that refuses the full flush gets a plain fsync
2026-10-13 13:26:55 +07:00
bflatastic 62036eac6c Image Trace: Flat Logo takes the tones of an unevenly lit colour for one colour
A scan's paper is a tenth darker in one corner than in another. Flat Logo found
each tone of it as a colour of its own and traced the bands and the specks
between them: on the scanned inputs 470 paths, 21 px from the art on average.

Two palette colours are now one when the pixels between them are no fewer than
a third of those at the lesser of the two: flat colours leave only their shared
edge between them, tones of one colour fill the gap. Whether the image is flat
art at all is still asked of the tones as found.

Flat Logo on its inputs, all sizes and variants: 3.106 -> 0.090 px off on
average, 88.5 -> 0.82 px at most, 69.9 -> 3.9 paths; clean, aliased, blurred
and noisy art as before.
2026-10-13 13:01:01 +07:00
bflatastic c49fb30032 Fix #1616: a tool flyout is as wide as its longest translated row
A flyout's rows were 250 pt wide whatever they held, so in Spanish (and
other languages with long tool names) a name ran into its shortcut. The
rows are now as wide as the longest name and shortcut need, and never
narrower than before.
2026-10-13 12:58:05 +07:00
bflatastic 0cc7e59df0 Save: a folder that refuses the temporary file or the rename gets the content copied in (part of #1620)
A save writes a temporary file beside the document and renames it over
it. Some network shares refuse one of those steps although the document
itself can be written. The save now falls back, without giving up what
the rename is for:

- the temporary file can't be created beside the document: it is made
  in the system's temporary folder (for its owner only);
- the rename is refused: the content, whole and flushed in the
  temporary file, is copied into the document itself;
- when that copy fails, the temporary file stays and the error names
  it; when the document can't be opened for writing, it is untouched
  and the first error is returned;
- permissions that can't be copied to the temporary file (SMB, FAT) no
  longer fail the save.

A folder that isn't there and a full volume fail as before.
2026-10-13 12:58:05 +07:00
Tomasz LewandowskiandClaude Opus 5.5 26ec0464bb contributors: add my name (lewandowskit)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33
2026-10-13 12:58:05 +07:00
dotCooCoo f0810ca58b Object: Lock and Hide › All Artwork Above go through each top-level layer once
Lock › All Artwork Above and Hide › All Artwork Above went through the
objects above each selected object in its top-level layer, one selected
object at a time. For each object they looked up in a list whether it
was selected or already found, and they computed its bounds again for
each selected object below it until one overlapped it. With 100 tiles
selected under 25,000 rectangles, a release build of vectorcraft-cli
took 6.4 s for Lock, and with all of 5,001 objects selected it took
8.8 s and locked nothing.

The commands now go through each top-level layer that holds a selected
object once, and check each object that is not selected against the
bounds of the selected objects below it. The selected objects and the
objects found are kept in sets, and each object's bounds are computed
once. The objects they lock or hide, the count, the selection and the
undo step are the same as before. On the same documents Lock now takes
under 0.1 s.

Docs: docs/target-app-parity.md.
2026-10-13 12:58:05 +07:00
dotCooCoo 35d6aa0a16 Render: pattern tiles at different scales are kept between frames
The renderer kept one tile per pattern name and ink plane, and used it
only at the raster scale it was made at: the device scale rounded up to
a power of two and capped. Two objects that paint one pattern at scales
for which the raster scale differs, such as a pattern scaled to 25%
with Transform Pattern Tiles next to the same pattern at 100%, replaced
each other's tile, and every frame rasterized both tiles again.

Tiles are now kept per pattern name and raster scale, within the tile
cache's budget of 128 MiB per thread and ink plane. When the budget
fills, the cache drops the least recently used tiles first. A tile is
still used only while the pattern's art, tile, tile type and overlap
are the ones it was made from. At 760%, with a pattern of 576 stars in
a 288 pt tile on a rectangle that fills the view, and the same pattern
at 25% on a square, release builds of vectorcraft-cli bench at
2880x1800 took a median of 26.0 ms a frame on main and 8.8 ms with this
change, with 8 threads.

Docs: docs/target-app-parity.md.
2026-10-13 12:58:05 +07:00
Marco Arnulfo 36dc6e0c11 Area type: a word too wide for the top lines of a shape moves down instead of breaking (fixes #1615) 2026-10-13 12:58:05 +07:00
Marco Arnulfo 791bd91d5c Strokes: a zero-length subpath draws its round or projecting cap as a dot (fixes #1613) 2026-10-13 12:58:05 +07:00
Tomasz LewandowskiandClaude Opus 5.5 17314d6b61 i18n(pl): confirm the maintainer-added Polish rows
Reviewed the rows added after the Polish catalog was written (graph
legend and position options, Transform Objects, Load EPS into
Clipboard, the unknown-parameter and image-size messages, …): the
translations are correct and read naturally, so no wording changes;
drop the "not reviewed by a native speaker" marker above them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33
2026-10-13 12:58:05 +07:00
Marco Arnulfo 544b17ec33 Workspace: rustix is declared once in the workspace dependencies 2026-10-13 12:58:04 +07:00
Marco Arnulfo 5e8581e42f Save: a network drive on macOS that refuses the full flush gets a plain fsync (fixes #1620) 2026-10-13 12:58:04 +07:00
BFlat 63686e659e Merge pull request #1628 from storytold/docs/image-trace-1013b
Image Trace: progress docs for the edge, stroke, memory and options work
2026-10-13 12:55:05 +07:00
bflatastic 7af5790b71 Image Trace: levelling moves no end of a line by more than 1.6 px
Snap Curves To Lines made every straight piece within 2.5 degrees of level or
upright exactly so, whatever its length: a 200 px line 2 degrees off had each end
moved 3.5 px. Now a piece is levelled only when neither end moves more than
1.6 px; a longer line that far off is left as the image has it.

Technical Drawing (the preset with the option on) on its inputs: largest
distance from the art 4.21 -> 1.46 px (1.43 before levelling existed), mean
0.178 -> 0.126 px; on the askew inputs 98% -> 70% of the outline comes out level
or upright.
2026-10-13 12:40:24 +07:00
bflatastic 8a91be7983 Progress docs: Image Trace edges, strokes, memory and the Shapes, Auto Grouping and Gradients options 2026-10-13 12:25:31 +07:00
BFlat 5bfe774420 Merge pull request #1627 from storytold/u/trace-gradients
Image Trace: Gradients
2026-10-13 12:24:35 +07:00
BFlat e918615347 Merge pull request #1623 from storytold/fix/1592-effect-params
Effects: effect.apply and effect.setParams refuse parameters the effect doesn't take (#1592)
2026-10-13 12:19:00 +07:00
bflatastic 250da4226e Image Trace: Gradients
Gradients (params `gradients`, a checkbox in the panel for Grayscale and Color;
off unless asked for): an area whose colour changes evenly along one direction
is traced as one path filled with a linear gradient, not as bands of flat colour.

Gradients are found before the palette is made, so no colour is spent on their
bands: a connected run of smoothly changing pixels is a gradient when a plane
fits each colour channel with all three sloping the same way, the colours along
that way are at most six stops with ramps between them, and painting them back
gives the area's own pixels. Flat colour, soft edges between flat colours and
shading that goes two ways are left as they were.

The harness gains two linear-gradient inputs with known lines and stops, and
measures whether each is found, the angle of its line and the colours of its ramp.
2026-10-13 12:16:03 +07:00
BFlat 17b4396a97 Merge pull request #1585 from p0lyg0n/fix/pdf-glyph-by-glyph-strokes
PDF/.ai import: type stroked glyph by glyph stays one object
2026-10-13 12:07:21 +07:00
BFlat 5adeada26d Merge pull request #1586 from p0lyg0n/fix/pdf-type-on-a-path-tracking
PDF/.ai import: evenly tracked type on a path keeps its length
2026-10-13 12:07:05 +07:00
bflatastic 175ad4a1e3 Effects: effect.apply and effect.setParams refuse parameters the effect doesn't take (#1592) 2026-10-13 11:53:38 +07:00
bflatastic 95304ad7f6 PDF import: a held glyph stroke takes its turn in the page's overprint list 2026-10-13 11:53:38 +07:00
Hiroshi InukaiandClaude Opus 5.5 02da9bbdc9 PDF/.ai import: evenly tracked type on a path keeps its length
Straight type takes the tracking that keeps its length as the file set
it; type on a path took none, so letter-spaced type on a curve came back
closed up to the font's advances (an "OFFICIAL ISSUED" badge sat to one
side of its band, a condensed title ran off the end of its path). Type on
a path now takes the same tracking, its length measured along the path
from the first glyph to the end of the last.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 11:53:38 +07:00
Hiroshi Inukai 331fc48b24 PDF/.ai import: type stroked glyph by glyph stays one object
Type on a path is written glyph by glyph, and a stroke added to it as each
glyph's outline stroked right after the glyph. The path in between ended the
line, so each glyph came in as its own text object. Such a stroke, in the
glyph's own paint, is now held with the line and written after it, where it
becomes the type's stroke as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/pdf/src/tests_import_lines.rs
2026-10-13 11:53:38 +07:00
BFlat 8d2d838884 Merge pull request #1618 from storytold/u/trace-groups
Image Trace: Auto Grouping
2026-10-13 11:47:48 +07:00
BFlat bc5bdf47d1 Merge pull request #1591 from switch87/fix/1590-offset-path-message
Offset Path: an empty result says the offset leaves nothing
2026-10-13 11:29:17 +07:00
BFlat 6b99150aac Merge pull request #1589 from switch87/fix/1584-icc-tag-alignment
ICC profiles written here have their tags 4-byte aligned
2026-10-13 11:28:57 +07:00
BFlat 26bb452ba1 Merge pull request #1576 from excited-raccoon/palette-nearest-spatial-index
Palette nearest-colour lookup: branch-and-bound index over the linear scan
2026-10-13 11:28:37 +07:00
BFlat dbb8bdcaaa Merge pull request #1571 from excited-raccoon/select-behind-linear
Select Behind: test each object once instead of re-hitting the whole document
2026-10-13 11:28:08 +07:00
BFlat ecf4ae7a3a Merge pull request #1577 from switch87/fix/ids-without-selection
Commands: objects given as ids stand in for the selection
2026-10-13 11:27:30 +07:00
BFlat e518974913 Merge pull request #1581 from switch87/fix/1578-arc-warp-aspect
Warp › Arc: bend in the object's real proportions
2026-10-13 11:27:17 +07:00
BFlat a20b0308b8 Merge pull request #1569 from switch87/feat/svg-flowroot-area-type
SVG import: Inkscape flowed text (flowRoot) becomes area type
2026-10-13 11:26:57 +07:00
BFlat 3126e82ebd Merge pull request #1572 from switch87/fix/1570-pathfinder-strokes
Pathfinder: Trim, Merge and Crop remove strokes
2026-10-13 11:26:39 +07:00
BFlat d8a298a310 Merge pull request #1612 from storytold/docs/progress-1013c
Docs: ROADMAP 2026-10-13 adds #1492–#1583; Average and Join corrected to missing, Polish row count
2026-10-13 11:26:05 +07:00
bflatastic 9c42a2942a Image Trace: Auto Grouping
Auto Grouping (params `autoGrouping`, a checkbox in the panel; off unless asked
for): each traced path that has others inside its outer contour comes out in a
group with them, the path first, nested as deep as the picture is. The path that
covers the whole image is nobody's group.

The grouped result paints the same picture: where paths can overlap (stacked
layers, Flat Logo, strokes) a path only joins a path below it, and only if that
moves it under nothing that shares any of its box.

The harness measures three presets with the option on ("grouped %"), painting
them in the grouped order.
2026-10-13 11:15:02 +07:00
BFlat 36286cc783 Merge pull request #1609 from storytold/u/trace-shapes
Image Trace: Shapes, and corners whose pixel is mostly outside the shape
2026-10-13 11:02:42 +07:00
bflatastic bc343414d8 Offset Path: the new message in the catalogs that translate messages 2026-10-13 10:52:38 +07:00
Gert Pellin 7b0164e4e7 ICC profiles written here have their tags 4-byte aligned
moxcms's encode packs tagged elements back to back, so the built-in
sRGB profile embedded in JPEG, TIFF and PSD exports was 634 bytes with
`cprt` at offset 558, where ICC.1 §7.1.2 requires every tagged element
on a 4-byte boundary (stricter readers reject the files). encode now
lays the tag data out again with each element padded, updating the tag
offsets, the size and the profile ID (zeroed, "not computed"); tags
that share data keep sharing it. Reported to moxcms as
awxkee/moxcms#198.

Fixes #1584
2026-10-13 10:52:38 +07:00
bflatastic 670946ef75 Palette index: rustfmt 2026-10-13 10:52:38 +07:00
bflatastic bdb9963c73 Select Behind: one test of what may be hit for both walks, and random documents checked against the repeated hit test 2026-10-13 10:52:38 +07:00
bflatastic dc64aaa41b Commands: ids stand in for the selection where the params description names the key, checked over every command 2026-10-13 10:52:38 +07:00
Gert Pellin 5366cf689e Offset Path: an empty result says the offset leaves nothing
With paths selected but every offset empty (an inset larger than the
shape), Offset Path failed with "select paths", sending the user to
look for a selection problem. It now says the offset leaves nothing of
the selected paths; "select paths" stays for selections without paths.

Fixes #1590
2026-10-13 10:52:38 +07:00
Ayanprogrammer11 cca3a63e4a Palette nearest-colour lookup: branch-and-bound index over the linear scan
Indexed export (GIF, PNG-8, BMP, TIFF) finds each pixel's nearest
palette entry with a linear scan, and the k-means refinement in median
cut does the same once per cell per pass. With the 256-entry palettes
this code produces, that is 256 distance evaluations per query.

PaletteIndex is an exact nearest-neighbour kd-tree over the palette,
balanced at the widest weighted axis, with tight channel bounds in every
subtree so branch-and-bound can skip whole boxes. Small palettes (12 or
fewer entries) keep the scan, which is cheaper than tree traversal.

Results match the linear scan exactly, including ties: an equal-distance
entry with a smaller index still wins. The bound uses the same weighted
squared f32 form as dist() rather than a transformed space, so rounding
cannot change which entry is chosen.

Three tests: randomized parity against the original scan across palette
sizes spanning the cutoff, three weight vectors, duplicate colours and
fractional query points; explicit tie-breaking cases; and an opt-in
benchmark comparing 100k queries at both, behind -- --ignored.
2026-10-13 10:52:38 +07:00
Ayanprogrammer11 507c6e0e63 Select Behind: test each object once instead of re-hitting the whole document
objects_at() found Select Behind targets by running hit_test_skipping()
in a loop, skipping everything found so far. Each round is a full tree
traversal, so a stack of n overlapping paths costs O(n^2) hit tests
before the loop finds no further object.

The fast path walks each editable top-level layer once. Every selectable
object's subtree is visited only until its first hit, and a HashSet
records found ids so subtrees already covered by an earlier hit are not
searched again. Sublayers recurse the same way, so a stack of n paths
costs one traversal total.

The original traversal stays in charge where Select Behind semantics
differ from the plain front-to-back order: isolation (scope), and Edit
Contents, which exposes objects nested inside a top-level group. Non-
layer roots in malformed files also fall back. objects_at_repeated()
keeps the old loop for those cases.

Three tests: a 256-path stack asserts front-to-back order and
completeness; a mixed document (sublayer, groups, clipping group,
outline and path-only options) asserts the one-pass result equals the
repeated-traversal result; an envelope in Edit Contents asserts the
fallback preserves the original order.
2026-10-13 10:52:38 +07:00
Gert Pellin dfc1b846cc Commands: objects given as ids stand in for the selection
Commands whose description takes `ids` (operate on these objects;
default: the selection) failed with "nothing selected" when objects
were given but none was selected: execute checked their `enabled`
predicate against the selection, with an exception for edit.clear only.
Of the 220 commands mentioning ids, 48 were refused so; their run
already used the ids. execute now evaluates `enabled` with the given
ids selected and puts the selection back, so each predicate keeps its
meaning (Make with Top Object still needs two objects).

Fixes #1575
2026-10-13 10:52:38 +07:00
Gert Pellin a169693817 Warp › Arc: bend in the object's real proportions
warp_point worked on box coordinates normalised to -1..1 on both axes:
Arc's radius was in x units but its edges were offset in y units, so a
box that isn't square bent into no circular arc (skewed sides from
Bend 10 %), and at 100 % the radius (2/π) was smaller than the
half-height, folding the inner edge over. warp_point now takes the
box's aspect along the warp; Arc keeps the centre line's length on a
circular arc in real proportions, with concentric edges, and an inner
edge that would pass the centre stops there. Other styles are as they
were; a square is bent as before.

Fixes #1578
2026-10-13 10:52:37 +07:00
bflatastic db85d465c8 SVG import: flowed text with a region too large to place is left out with a warning 2026-10-13 10:52:37 +07:00
Gert Pellin de8d5c96ab Pathfinder: Trim, Merge and Crop remove strokes
The public Pathfinder documentation says Trim and Crop remove any
strokes, and Merge removes them and merges same-coloured fills. The
pieces kept every stroke, and Merge, grouping by the whole style, left
same-filled objects with different strokes apart. The three now drop
stroke layers before styles are compared.

Fixes #1570
2026-10-13 10:52:37 +07:00
bflatastic d44615753a Docs: ROADMAP 2026-10-13 adds #1492–#1583; Average and Join corrected to missing, Polish row count 2026-10-13 10:52:37 +07:00
Gert Pellin e794c1ac63 SVG import: Inkscape flowed text (flowRoot) becomes area type
<flowRoot> (SVG 1.2 flowed text, which Inkscape writes for text typed
into a frame) was dropped without a word: usvg doesn't draw it. The
text pass now reads it as area type: the first <rect> of its
<flowRegion> is the frame (with the rect's transform), each <flowPara>
a paragraph (white space collapsed), styled through the same cascade
as <text>. Another kind of region leaves the text out with a warning.

Fixes #1568
2026-10-13 10:52:37 +07:00
BFlat 0573d3cdfb Merge pull request #1596 from storytold/fix/1561-symbol-percentages
SVG import: percentage lengths in a symbol with a viewBox are of the viewBox
2026-10-13 10:26:19 +07:00
bflatastic 9ca74ac011 Image Trace: Shapes, and corners whose pixel is mostly outside the shape
Shapes (params `shapes`, a checkbox in the panel; off unless asked for): a filled
outline that is a circle, an ellipse, a rectangle or a regular polygon comes out
as that live shape. A shape is only made when every point of the traced outline
lies within 0.5 px (0.2% of the shape's size, if more) of it; an ellipse whose
radii differ by less than twice that is a circle of its area.

The outline before fitting: a corner's own pixel no longer pulls the two edges
that meet there, and a one-pixel step between two long edges is the corner where
they meet. Sharp corners kept: Default 69% -> 76%, the colour presets 86% -> 91%.

The harness gains plain-shape inputs and "shapes %".
2026-10-13 10:25:05 +07:00
BFlat f0f3cb4eb1 Merge pull request #1595 from storytold/u/trace-strokes
Image Trace: stroked centre lines lie in the middle of their ink and are as wide as it
2026-10-13 10:14:01 +07:00
bflatastic 5fe916a425 SVG import: percentage lengths in a symbol with a viewBox are of the viewBox (#1561) 2026-10-13 09:58:14 +07:00
bflatastic 8e7b9ba436 Image Trace: stroked centre lines lie in the middle of their ink and are as wide as it
Create: Strokes (the Outline preset, or the option) traced lines along the pixel skeleton: right to a pixel,
with a width counted in pixels, and fitted by a separate, older fitter.

- Each skeleton point is moved to the middle of the line's ink, read from the image across the line: every
  pixel counts as much ink as its colour is the line's rather than the paper's, the ink's centroid is the
  middle and its sum the width (an anti-aliased or blurred line says both to a fraction of a pixel).
- The stroke width is the median of those widths, no longer a whole number of pixels.
- Each end goes where the ink ends, less the half width a round cap adds.
- Centre lines are fitted as outlines are: a straight line is two anchors, corners stay corners, Snap Curves To
  Lines levels them. The old Douglas-Peucker and simplify fitter, and the crate's pathops dependency, go.

Outline on the thin-line inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- painted outline 0.310 -> 0.055 px off on average; 81 -> 14 anchors for five lines
- centre line 0.295 -> 0.052 px from the true centre line; stroke width 0.13 -> 0.02 px off; 18.4 -> 2.7
  anchors a stroke (blurred, noisy and scanned lines: 2.0 each; aliased: 4.6)
- largest distance 1.84 -> 2.94 px: the square ends of a 10 px line under a round cap
Presets without Create: Strokes are unchanged.
2026-10-13 09:36:47 +07:00
BFlat cbcee3bd54 Merge pull request #1593 from storytold/u/trace-upright
Image Trace: Snap Curves To Lines levels lines a degree or two askew
2026-10-13 09:36:14 +07:00
BFlat 0810a15f6e Merge pull request #1566 from marcoarnulfo/fix/new-symbol-options-dialog
Symbols panel: New Symbol asks for Symbol Options before making the symbol
2026-10-13 09:31:54 +07:00
BFlat db7e568809 Merge pull request #1567 from excited-raccoon/css-class-dedup-hashmap
Replace linear scan in CSS class dedup with a HashMap lookup
2026-10-13 09:31:36 +07:00
BFlat 9d72686d95 Merge pull request #1565 from switch87/fix/1557-css-transform-font
SVG import: CSS transforms in CSS syntax move objects; the font shorthand styles type
2026-10-13 09:31:20 +07:00
BFlat d79fb145c2 Merge pull request #1558 from marcoarnulfo/fix/zigzag-regular-waveform
Zig Zag: ridges are spaced evenly along each segment's length, so a straight line gets a regular wave
2026-10-13 09:31:04 +07:00
BFlat 6a16627de6 Merge pull request #1583 from storytold/fix/1534-guides
Fix #1534: guide paths take the guide colour and style, and a dragged selection snaps to ruler guides with Smart Guides on
2026-10-13 09:09:09 +07:00
BFlat acc1c9a85d Merge pull request #1582 from storytold/fix/1533-join-coincident
Fix #1533: Join merges the selected anchors that lie on one another (after Average) into one
2026-10-13 09:08:58 +07:00
BFlat 8fbf724cf6 Merge pull request #1580 from storytold/fix/1539-shortcut-menuless
Keyboard Shortcuts: the commands outside the menus that run without params are listed too (#1539)
2026-10-13 09:08:47 +07:00
BFlat 02ed637c68 Merge pull request #1544 from switch87/fix/1542-clip-covers-page
PDF/EPS import: a clip only counts as covering the page when it does
2026-10-13 09:08:21 +07:00
BFlat 9d2b074d12 Merge pull request #1543 from marcoarnulfo/fix/guide-objects-locked
Guides: Lock Guides locks the paths made into guides too
2026-10-13 09:08:05 +07:00
BFlat 632ea2a9f3 Merge pull request #1541 from switch87/fix/1536-effect-raster-paint-transform
Render: effect rasters drawn after a pattern fill keep their place and size
2026-10-13 09:07:48 +07:00
BFlat 523fd6e185 Merge pull request #1549 from marcoarnulfo/fix/duplicate-shortcut
Keyboard Shortcuts: Duplicate is listed, so it can be given a shortcut
2026-10-13 09:07:31 +07:00
bflatastic 612082e26e Image Trace: Snap Curves To Lines levels lines a degree or two askew
The documented option "replaces slightly curved lines with straight lines and snaps lines near 0 or 90 degrees
to absolute 0 or 90 degrees" (helpx, Image Trace panel options); ours did the first half only.

- With Snap Curves To Lines, a straight piece at least 2 px long and within 2.5 degrees of level or upright is
  made exactly so, by moving its ends across it to their mean (the page gives no angle: 2.5 degrees is our
  choice). The pieces next to a moved end follow it.
- A junction other regions share stays where it is, so abutting regions still have one curve along their edge;
  a line between a junction and a free end is levelled to the junction.
- The harness gains askew inputs (a rectangle, a plus sign and two lines, 1 to 2 degrees off level and upright)
  and an "upright %" column: the share of the outline length that is exactly level or upright.

On the clean askew inputs (docs/image-trace-quality.md): Black and White Logo and Technical Drawing, the presets
with the option on, go from 0% to 91% and 95% of their outline exactly level or upright. Their outlines are then
0.49 and 0.59 px from the art as drawn where they were 0.05 (4.2 px at the far end of a 300 px side turned 1.5
degrees): that distance is what the option is for. Presets without the option are unchanged.
2026-10-13 09:07:13 +07:00
bflatastic fea316911d Symbols panel: Alt-click on New Symbol skips Symbol Options 2026-10-13 08:53:06 +07:00
Ayanprogrammer11 3af08505f8 Replace linear scan in CSS class dedup with a HashMap lookup
Export::class() deduplicated declaration blocks with
classes.iter().position(), an O(n) scan per style, making exports with
many distinct styles (e.g. large palettes) quadratic overall. Track the
1-based class number per declaration in a HashMap alongside the
first-use-ordered Vec, so each lookup is O(1) while output stays
byte-identical.

Also adds a regression test covering 256 distinct declarations with
recurrence of early styles, for both InternalCss and StyleEntities
styling.
2026-10-13 08:53:06 +07:00
Gert Pellin 2c01a42b35 SVG import: CSS transforms in CSS syntax move objects; the font shorthand styles type
usvg takes a `transform` CSS property as a presentation attribute that
overrides the element's own, but reads its value as SVG: CSS units
(`translate(100px, 0)`, `rotate(45deg)`) don't parse, and the object
was left with no transform at all. Before usvg runs, transform
declarations in `style` attributes and `<style>` rules are now written
in SVG syntax (lengths in px, angles in deg/rad/grad/turn, translateX/Y,
scaleX/Y and skew as SVG's functions); values that can't be are left.

The text pass's own cascade now expands the `font` shorthand into its
longhands, as usvg does for the rest of the drawing, so text styled
with `font: italic bold 30px …` keeps its size, weight and style.

Refs #1557
2026-10-13 08:53:06 +07:00
bflatastic 45334df881 Zig Zag: tests of the ridge spacing on a circle and on an uneven curve 2026-10-13 08:53:06 +07:00
Marco Arnulfo ee97225c19 Symbols panel: New Symbol asks for Symbol Options before making the symbol (fixes #1560) 2026-10-13 08:53:06 +07:00
Marco Arnulfo 6085197bf4 Zig Zag: ridges are spaced evenly along each segment's length, so a straight line gets a regular wave (fixes #1554) 2026-10-13 08:53:05 +07:00
bflatastic 50911d8608 Fix #1534: a dragged selection snaps to ruler guides with Smart Guides on too 2026-10-13 08:51:43 +07:00
bflatastic 6939eff2f9 Fix #1534: paths made into guides take the guide colour and style of Preferences 2026-10-13 08:51:43 +07:00
Gert Pellin 9652d1d6b2 PDF/EPS import: a clip only counts as covering the page when it does
Both readers skipped a clip of at most six straight segments whose
bounding box contained the page. A diamond touching the page edges has
the page as its bounding box, so its clip was dropped and everything it
cuts away showed. geom::hit::polygon_covers_rect answers it properly
(one convex polygon containing the page's corners); any other path is
kept as a clip.

Fixes #1542
2026-10-13 08:51:40 +07:00
Marco Arnulfo 79b5d73ca7 Guides: Lock Guides locks the paths made into guides too (part of #1534) 2026-10-13 08:51:38 +07:00
Gert Pellin 05fdd15b45 Render: effect rasters drawn after a pattern fill keep their place and size
fx.rs draws blur, shadow and glow rasters as image paints that map 1:1
onto their rect, but only set the geometry transform: the paint
transform a pattern fill (or a radial or freeform gradient) left on the
context still applied, so in supersampled renders (raster exports) the
effect came out tiny and displaced. Both draws now reset the paint
transform first.

Fixes #1536
2026-10-13 08:51:37 +07:00
bflatastic 0a16b62a22 Fix #1533: Join merges the selected anchors that lie on one another (after Average) into one 2026-10-13 08:51:34 +07:00
bflatastic 20f5174710 Keyboard Shortcuts: the commands outside the menus that run without params are listed too (#1539) 2026-10-13 08:51:32 +07:00
Marco Arnulfo 00195e8d28 Keyboard Shortcuts: Duplicate is listed, so it can be given a shortcut (fixes #1539) 2026-10-13 08:51:30 +07:00
BFlat 05e321f4e0 Merge pull request #1574 from storytold/u/trace-options
Image Trace: Ignore Color, and the Auto Color and Outline presets
2026-10-13 08:47:04 +07:00
BFlat 85770d3afb Merge pull request #1553 from switch87/fix/1552-type42-em
EPS/PS import: Type 42 fonts set type at their size
2026-10-13 08:27:05 +07:00
BFlat 421443b8ac Merge pull request #1551 from switch87/fix/1550-pdf-alpha-softmask-images
PDF import: images in an alpha soft mask count by their alpha
2026-10-13 08:26:49 +07:00
Gert Pellin e2a63a8baf EPS/PS import: Type 42 fonts set type at their size
Interp::font took a font's FontMatrix times 1000 as the matrix of one
em, the Type 1 convention (glyphs in 1000-unit space). A Type 42 font's
glyph space is one unit an em, its FontMatrix usually the identity, so
type in Type 42 fonts (cairo, Inkscape, LibreOffice) came in 1000 times
too large and off the page. FontType 42 now takes the matrix as it is.

Fixes #1552
2026-10-13 08:11:27 +07:00
bflatastic 44d170f059 PDF import: pattern swatches read inside an alpha mask keep their images; a luminosity mask test 2026-10-13 08:11:27 +07:00
Gert Pellin fce90e3144 PDF import: images in an alpha soft mask count by their alpha
An alpha soft mask's vector art is whitened so its luminance is its
alpha, but images kept their colour: cairo (and so Inkscape) draws a
mask as an image whose alpha is in its own SMask, often black pixels,
so the mask came in empty. While reading an alpha mask's art the
importer now reads images white with their own alpha (under their own
cache key, without the JPEG and CMYK shortcuts), and stencil masks
white; a luminosity mask nested inside reads them as they are.

Fixes #1550
2026-10-13 08:11:27 +07:00
bflatastic 761cc2371a Image Trace: Ignore Color, and the Auto Color and Outline presets
Against the documented panel (https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/image-trace-panel-options.html):

- Ignore Color: "Ignores a specific color while tracing. Use the color picker to selectively ignore a color."
  The panel's Ignore White is now Ignore Color with a colour button beside it (white until another colour is
  picked); `imageTrace.make` takes `ignoreColor: "#rrggbb"` beside `ignoreWhite`, and leaves out the palette
  colours within about 30 levels of it. Documents saved before have no such setting and ignore white as they
  did. Flat Logo mode leaves out white only.
- Auto Color ("Creates a posterized image from a photo or artwork"): Color mode with the Automatic palette.
- Outline ("Simplifies the image to black outlines"): Black and White with Create: Strokes and no fills.
- The Outline preset's name is translated as a preset's name, not as the Outline view's.

Measured (docs/image-trace-quality.md): Auto Color on flat art is 0.140 px off on average with 4.0 paths (16
Colors: 0.141, 4.2). Outline on the line inputs, the first measure of Create: Strokes: 0.310 px off on average,
1.84 at worst, 81 anchors for 5 lines (Line Art's filled outlines: 0.055, 0.96, 46).
2026-10-13 08:04:32 +07:00
BFlat 3752b3ec31 Merge pull request #1563 from storytold/u/trace-thin
Image Trace: thin lines in blurred and scanned art keep smooth edges
2026-10-13 07:50:58 +07:00
bflatastic c2ed31dc4c Image Trace: thin lines in blurred and scanned art keep smooth edges
Black and White read every pixel as a blend of "black" and "white", and took for those the mean luminance of
the image's black and white pixels. In blurred or scanned line art most black pixels are part paper, so the
mean black was a gray: the middle of every line counted as more than all ink, and by how much changed as the
line crossed the pixel rows, which put a ripple on its edges that the fit then followed with anchors.

- Black is the image's darkest black (the 2nd percentile of its black pixels' luminance).
- White is the lightest pixel within a few pixels of each crack: the paper as it is there. A scan's paper is
  darker in one corner than in another, and read against the image's lightest white it counted as part ink.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- Line Art: 78 -> 46 anchors, outlines 0.093 -> 0.055 px off on average (blurred lines 168 -> 53 anchors,
  scanned 199 -> 70); before sub-pixel edges it was 25 anchors and 0.158 px off
- Technical Drawing: 56 -> 46 anchors, 0.092 -> 0.084 px off
- Sketched Art: 0.197 -> 0.186 px off; Default 0.092 -> 0.090; Black and White Logo 0.063 -> 0.059 with
  27 -> 29 anchors
Not kept: reading lines up to six pixels deep by their ink's centroid and width, and a looser tolerance for
straight runs, neither of which measured better.
2026-10-13 07:27:56 +07:00
BFlat d9e44350d2 Merge pull request #1548 from storytold/fix/1529-fontconfig-dirs
Fix #1529: Linux reads the font folders fontconfig is set up with
2026-10-13 07:14:31 +07:00
BFlat 0f4de5b98e Merge pull request #1547 from storytold/fix/1512-corners-smooth
Fix #1512: round corners next to curves stay inside the corner, and the Smooth tool smooths what is under the drag
2026-10-13 07:14:14 +07:00
BFlat a971b5b70e Merge pull request #1546 from storytold/u/trace-memory
Image Trace: a 16 megapixel photo is traced in 0.9 GB, not 3.9
2026-10-13 07:14:01 +07:00
BFlat 6beaa89be2 Merge pull request #1531 from switch87/fix/1527-textpath-basic-shapes
SVG import: <textPath> follows basic shapes (circle, ellipse, rect, line, polyline, polygon)
2026-10-13 07:13:45 +07:00
BFlat 40b02f479b Merge pull request #1530 from EricKuck/fix/ai-gradient-stop-order
AI import: gradient stops listed upwards aren't reversed, so a midpoint stays on the stop that starts its part of the ramp
2026-10-13 07:13:29 +07:00
BFlat afda1cd846 Merge pull request #1528 from fortesnow/ai-off-page-art-overlap
.ai import: a page's art past its artboard no longer lies over the page before it
2026-10-13 07:13:12 +07:00
BFlat a7c6644691 Merge pull request #1545 from storytold/docs/progress-1013b
Docs: ROADMAP 2026-10-12 adds #1474–#1525 (late merges of the day)
2026-10-13 07:12:46 +07:00
bflatastic 3a88ad0855 Fix #1529: Linux reads the font folders fontconfig is set up with
The font scan read /usr/share/fonts, /usr/local/share/fonts, ~/.fonts and the
XDG data folders. Packages add folders outside those to fontconfig's
configuration (TeX Gyre under /usr/share/texmf/fonts), so fc-list and other
apps showed fonts VectorCraft didn't have. The scan now also reads the <dir>
folders of /etc/fonts/fonts.conf and the files it includes (conf.d, the user's
own), as the fontconfig documentation describes them. The files are read as
they are: no library is linked and no program is run.
2026-10-13 06:56:37 +07:00
bflatastic 61cc3f6040 Fix #1512: the Smooth tool smooths the stretch of path under the drag
A drag near an anchor refitted the whole segments on either side of it, so a
short drag over a small bump on a long side turned the side into a curve from
end to end. The segments leading into the anchors under the drag are now split
where they enter the tool's reach, and only what is in reach is refitted.
2026-10-13 06:56:37 +07:00
Gert Pellin 3239e8b97e SVG import: <textPath> follows basic shapes (circle, ellipse, rect, line, polyline, polygon)
A <textPath href="#id"> pointing at a basic shape imported as point type
with "textPath has no usable path", as only <path> was followed. SVG 2
allows any basic shape, and Inkscape writes one whenever text is put on
a circle. The shape now becomes its equivalent path as SVG 2 defines
it: circles and ellipses start at 3 o'clock and rounded rects after the
top-left corner, all clockwise; lines, polylines and polygons follow
their points. The shape's transform applies as for a <path>. Shapes
that render nothing (zero radius or size) still fall back to point
type with the warning.

Fixes #1527
2026-10-13 06:56:37 +07:00
EricKuckandClaude Opus 5.5 48d5bd1aee AI import: gradient stops listed upwards aren't reversed, so a midpoint stays on the stop that starts its part of the ramp
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 06:56:37 +07:00
bflatastic 5203f4f047 PDF import: the type of a .ai page moved right keeps the stroke drawn on its glyphs
The ink boxes of the page's lines of type move with its art, and art at a
coordinate that isn't a number moves nothing.
2026-10-13 06:56:37 +07:00
bflatastic 9dfa7bf931 Docs: ROADMAP 2026-10-12 adds #1474–#1525 (late merges of the day) 2026-10-13 06:56:37 +07:00
bflatastic a072e29016 Fix #1512: a round corner next to a curved side no longer sticks out of the corner
Live Corners cut a corner between a curve and another side with the handles of
the arc between straight sides. The curve has turned by the time the cut meets
it, so those handles reached past where the tangents at the cut's ends cross,
and from a middling radius on the cut bulged out of the shape or looped. Each
handle is now the share an arc takes of its tangent up to that crossing.
Corners between straight sides are cut as before.
2026-10-13 06:56:37 +07:00
fortesnow ba68fcfbfc .ai import: a page's art past its artboard no longer lies over the page before it
A PDF-compatible .ai keeps its art past the artboard edge unclipped (#646),
and its pages are laid out in a row 36 pt apart. Art that a page draws far
to the left of its artboard (notes beside a design, say) then lay over the
page before it. There it was taken for that page's art: the check against
the editing data found art its layers don't have and opened the file as
its PDF part, without the art outside its artboards, and place_pages left
that art behind when it moved the pages.

Such a page now moves right until its art clears the art before it.
Plain PDFs (clipped to the page) keep the 36 pt gap.
2026-10-13 06:56:37 +07:00
BFlat 02a68e1738 Merge pull request #1540 from storytold/fix/1479-too-large-note
Part of #1479: an image too large to decode is reported on Open, Place and in the Links panel
2026-10-13 06:43:25 +07:00
bflatastic c9fac95760 Image Trace: a 16 megapixel photo is traced in 0.9 GB, not 3.9
Every colour layer took a mask, component labels, a fill stack and an edge map the size of the whole image,
and as many layers were traced at once as the machine has cores.

- A layer is traced within the box its pixels lie in.
- The stack that fills components holds `u32` indices (a mask has at most 64 million pixels).
- Layers are traced as many at a time as fit in half a gigabyte (about ten bytes a pixel of the largest
  layer's box each), on however many cores.

Peak memory above the image itself, photo-like image (docs/image-trace-quality.md), before -> after:
- 16 megapixels: High Fidelity Photo 3,856 -> 880 MB (2.85 -> 3.65 s), Low Fidelity Photo 1,972 -> 754 MB
  (1.49 -> 1.80 s), 16 Colors 1,625 -> 298 MB, Shades of Gray 852 -> 407 MB, Default 256 -> 199 MB
- 4 megapixels: High Fidelity Photo 981 -> 643 MB, 16 Colors 408 -> 122 MB, times unchanged
The traces are the same, path for path.
2026-10-13 06:38:10 +07:00
BFlat b631002c58 Merge pull request #1538 from storytold/fix/1499-paste-pdf-eps
Fix #1499: PDF and EPS another app copied paste as editable vector art
2026-10-13 06:36:24 +07:00
BFlat 041776fc9f Merge pull request #1537 from storytold/fix/compound-group-paint
Make Compound Path: the compound path of a group takes its backmost path's paint
2026-10-13 06:36:02 +07:00
BFlat 78155ed5b8 Merge pull request #1498 from lewandowskit/i18n-polish
i18n: add Polish (pl) interface language
2026-10-13 06:34:52 +07:00
BFlat 51c18f603a Merge pull request #1492 from switch87/fix/1484-compact-anchor-heavy-saves
Native save: documents whose JSON is large are written compact
2026-10-13 06:34:35 +07:00
bflatastic 66add30d65 Part of #1479: an image too large to decode is reported on Open, Place and in the Links panel
An image whose decode needs more than the 2 GiB allowance (512 MB on the web) is not drawn.
A raster export says so since #1517; on screen it was still blank with no word. Open and
Place now carry a warning for it, and the Links panel's row a note (links.list: note).
2026-10-13 06:34:12 +07:00
bflatastic b3f6edddfe Fix #1499: PDF and EPS another app copied paste as editable vector art
Paste read PDF only from the Windows clipboard and EPS nowhere, so on macOS a copied
equation or drawing pasted nothing new (the last thing copied in the app came back).
- macOS: the pasteboard's PDF, EPS and SVG are read, ahead of text and bitmaps.
- Windows: EPS is read beside PDF.
- Everywhere: an .eps file copied in a file manager pastes.
- clipboard.importEps loads an EPS into the clipboard as document.open reads one.
2026-10-13 06:34:12 +07:00
bflatastic a046fd30fe Make Compound Path: the compound path of a group takes its backmost path's paint
A group has no paint of its own, so a compound path made of one (outlined type) had none
and drew nothing until a fill was set. The paint now comes from the backmost path, or
from the compound path that painted it.
2026-10-13 06:34:12 +07:00
bflatastic 1e5f430ac5 Polish: the strings added since the catalog was written 2026-10-13 06:34:12 +07:00
Gert Pellin f3040e3762 Contributors: add my real name 2026-10-13 06:34:12 +07:00
Tomasz LewandowskiandClaude Opus 5.5 db54eefeae i18n: add Polish (pl) interface language
Complete Polish catalog (4,492 rows): every menu string, tl! literal,
plural and status/error message, enforced by the existing completeness
tests (complete_menus, KEEPS_MENU_NAMES, COMPLETE_MESSAGES).

- pl.tsv: clean-room translation from the English meaning, following
  common Polish desktop-software conventions (obszar kompozycji for
  artboard, obszar roboczy for workspace, obrys, wypełnienie, próbki…)
- plural_polish: one / few (2–4, not 12–14) / many (0, 5–21, 25…)
- every pl-* locale (pl-PL, pl_PL.UTF-8) resolves to it
- tests: Polish plural forms, vocabulary, nested message translation;
  Polish letters in the bundled-font coverage test
- docs: README, development.md, localization-parity.md

Drafted with AI assistance; native review welcome.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33
2026-10-13 06:34:12 +07:00
Gert Pellin 37954523ae Native save: documents whose JSON is large are written compact
SaveOptions::for_doc chose pretty-printed JSON by the object count in
the layers alone. A document with few layer objects but many anchors,
or with its art in symbols, patterns or graph designs, was therefore
written pretty, about ten times the compact size: every anchor's "p",
"in" and "out" spread over several indented lines.

A document is now pretty only when it has at most PRETTY_MAX_OBJECTS
objects and its compact JSON is at most PRETTY_MAX_BYTES (1 MiB). The
size is measured by serializing into a writer that stops at the limit,
so a large document costs at most 1 MiB of extra serializing. Small
documents stay readable and diffable.

Fixes #1484
2026-10-13 06:34:12 +07:00
BFlat c391e1dbf2 Merge pull request #1535 from storytold/u/trace-shared
Image Trace: abutting regions share one curve along their edge (no gaps, no overlaps)
2026-10-13 06:27:57 +07:00
bflatastic 20ec49b2e7 Image Trace: abutting regions share one curve along their edge
Each region's outline was fitted on its own, so the edge between two regions was fitted twice, a little
differently, and left hairline gaps and overlaps.

- An outline is cut at its junctions: the pixel corners where three or more regions meet, where two parts of a
  region touch by a corner only, and where an edge reaches the image's border (each side of the image is a
  neighbour of its own, so the image's corners stay exact). A junction lies where the edges around it say, the
  same for every region around it.
- Each piece between two junctions is the whole edge between two regions. Both get exactly the same points for
  it (shifts are rounded to 1/1024 px so that the two sides agree to the last bit; whether a piece is hard is
  decided by the piece alone) and it is fitted the same way from either side (from its end further left), so it
  is one and the same curve in both paths. A loop without junctions is fitted clockwise from its leftmost point:
  a shape's outline and the hole it leaves in the shape around it are one curve too.
- Stacked layers (Overlapping) share no edges and are cut at the image's border only.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- Default: gaps 22.2 -> 0.8 px, overlaps 22.4 -> 0.5 px
- 3 Colors: gaps 48.6 -> 0.0, overlaps 29.4 -> 0.0; outlines 0.087 -> 0.104 px off on average
- 6 Colors: gaps 39.0 -> 0.2, overlaps 35.7 -> 0.3; 0.137 -> 0.140 px off, largest 1.09 -> 1.34
- 16 Colors: gaps 34.5 -> 0.2, overlaps 26.4 -> 0.2; 0.133 -> 0.141 px off, largest 1.09 -> 1.31
2026-10-13 06:00:40 +07:00
BFlat 8715130c8b Merge pull request #1522 from anders94/readme-fix
README.md: add clone instructions to quick start
2026-10-13 05:57:22 +07:00
BFlat 6689be0351 Merge pull request #1526 from AyberkA0/feat/3d-inflate-twist-taper-cap
3D and Materials: Inflate takes Twist, Taper and Cap
2026-10-13 05:56:34 +07:00
BFlat c2c8a07b75 Merge pull request #1525 from AyberkA0/fix/3d-twisted-walls
3D and Materials: twisted Extrude & Bevel walls keep their faces
2026-10-13 05:56:18 +07:00
BFlat b83eafd794 Merge pull request #1524 from AyberkA0/fix/3d-twist-range
3D and Materials: Extrude & Bevel's Twist runs 0° to 360°, as documented
2026-10-13 05:56:03 +07:00
BFlat e93fc20294 Merge pull request #1523 from dotCooCoo/feat/mac-menu-sync-skips-drag-steps
macOS: the app no longer reads the menus at every step of a move, scale or shape drag
2026-10-13 05:55:48 +07:00
BFlat 9e2a7c41a0 Merge pull request #1519 from dotCooCoo/feat/many-path-edits-one-pass
Part of #1429: path, Pathfinder and live-object commands add and remove many objects in one pass
2026-10-13 05:55:31 +07:00
BFlat a4ba47a439 Merge pull request #1515 from dotCooCoo/feat/many-row-edits-one-pass
Part of #1429: Layers panel and artboard commands on many rows take time linear in the document
2026-10-13 05:55:21 +07:00
BFlat 93e8c00607 Merge pull request #1517 from fastmikey99/fix/decode-print-size-rasters
Decode print-size rasters in Crop Image trim and the renderer; say when an export is missing an image
2026-10-13 05:55:05 +07:00
Anders Brownworth 1e0063258a README.md: add clone instructions to quick start 2026-10-13 05:22:01 +07:00
Ayberk A. 15cef94ba6 3D and Materials: Inflate takes Twist, Taper and Cap
The public 3D and Materials panel options list Depth, Twist, Taper and Cap
for every 3D type; Inflate had only Depth.

- Twist (0° to 360°) and Taper (1–100 %) turn and scale the back about the
  shape's centre, as Extrude & Bevel's do: a puffed back is turned and
  lowered in proportion, and the sides follow in slices.
- Cap: Solid closes the back; Hollow leaves it off, so the inflated front is
  a shell open behind. The documentation doesn't say what Cap means for
  Inflate; this reading is stated in docs/three-d.md.
- The dialog's Object tab shows them in the documented order (Depth, Twist,
  Taper, Cap), then Volume and Both sides.

At 0 % volume a twisted, tapered Inflate has the extrusion's outline from
every side (tested).

A twisted, tapered, hollow Inflate draws on the canvas exactly as it
exports, and expands to faces with their own ids (tested). ROADMAP and the
parity pages list the new options; the 3D score stays at ~25 %.
2026-10-13 05:22:00 +07:00
Ayberk A. 431dc43e43 3D and Materials: twisted Extrude & Bevel walls keep their faces
A twist bends the strip between two slices into a saddle; its normal came
from one corner's triangle, which leaned so far back that from about 60° of
twist every wall was taken for facing away and dropped, leaving the front
cap alone. Each strip now takes the whole quad's average normal (Newell's),
as Inflate's facets do. A test checks the faces kept against each strip's
average normal oriented away from the axis, for twists up to 300° and four
views.
2026-10-13 05:21:41 +07:00
Ayberk A. e750891912 3D and Materials: Extrude & Bevel's Twist runs 0° to 360°, as documented
The public 3D and Materials panel options give Twist as up to 360°; it took
-360° to 360°. Values beyond either end now hold at that end (the effect is
a day old, so only fresh files carry a negative twist).
2026-10-13 05:21:32 +07:00
dotCooCoo fb2089aaf5 macOS: the app no longer reads the menus at every step of a move, scale or shape drag
On macOS, native_menu::sync reads every row of the menus (each
command's enabled and checked state, and each label) whenever
state_hash changes, and the macOS backend updates the AppKit items
whose label, enabled or checked state changed. The hash included the
active document's revision, and Session::preview makes a new revision
at each step of a drag, so the app read every row again at each step
of a move, scale or shape drag. In a release build on an M2 Max, that
took about 1.5 ms a step with one object selected, and 13.8 ms a step
with 5,001 objects selected.

While an interaction is open, state_hash leaves the revision out.
Move, scale and shape drags, dialog previews and the font menu's
preview are interactions. The menus are read when an interaction
begins and again on the frame it ends: when a drag's button is
released, when Escape cancels a drag, when a move is dragged onto a
panel, when a dialog's preview is applied or canceled, when the font
menu's preview ends, or when a control request such as
ui.dialog.cancel ends it. They are still read on a click or a key
press during an interaction.

docs/development.md says when the menus are updated during an
interaction.
2026-10-13 05:21:32 +07:00
dotCooCoo 88bd4812e0 Part of #1429: path, Pathfinder and live-object commands add and remove many objects in one pass
Offset Path, Outline Stroke, the Pathfinder panel, Clean Up, Make
Blend, Rasterize and the other commands that #1429's table marks 3,
except Split Into Grid, added, removed or replaced their objects one at
a time, and so did Remove Anchor Points, Redefine Symbol and the
removal of Shaper groups whose source art is gone. Each insertion or
removal moved the later objects of the layer or group. The lookups by
id that followed walked the tree, and every fourth walk rebuilt the
lookup cache. On 25,000 rectangles a release build of vectorcraft-cli
took 29.1 s for Offset Path.

These commands now make their edits with Document::wrap,
Document::insert_beside and Document::remove_many, in one pass over the
children of each layer or group, and Outline Stroke and Expand write
each new object into the place of the object it replaces. A list that
holds an object twice, or an object and another inside it, and that was
edited without an error before, is still edited one object at a time.
The documents, selections and undo steps are the same. On the same
rectangles Offset Path now takes 1.3 s.

Docs: docs/target-app-parity.md.
2026-10-13 05:21:31 +07:00
dotCooCoo 6f9e2d5c16 Part of #1429: Layers panel and artboard commands on many rows take time linear in the document
These commands removed, moved or copied one row or object at a time:
the Layers panel's Delete Selection, Duplicate Selection, Collect in
New Layer, row drags, Merge Selected, Flatten Artwork and Release to
Layers, and Cut Artboard, Duplicate Artboard and the Artboard tool's
Alt-drags that copy art. Each edit moved the later rows of the same
layer or group to other indices. The next lookup by id then walked the
tree, and after every fourth such walk Document::index_path rebuilt
the whole lookup cache. Release to Layers also walked the document
twice for each new layer to name and color it. With 25,000 rectangles
in one layer, a release build of vectorcraft-cli took 11.9 s for
Duplicate Selection and 17.7 s for Release to Layers.

The commands now remove, move and copy the rows in one pass over each
layer or group, with Document::remove_many and Document::wrap, and
with copy_above, the copy pass of duplicate_in, which Duplicate
Selection and the artboard copies now share. Release to Layers names
its new layers from one walk of the document. Merge Selected checks
the target layer's objects against a set of the listed rows, and
Delete Selection checks the top-level layers against such a set. In a
document whose ids are unique, the rows keep their order, new rows and
layers take the same ids, names and colors, and the selection, the
undo step and the journal are the same. A release build with this
change takes 98 ms for Duplicate Selection and 26 ms for Release to
Layers.

Docs: docs/target-app-parity.md.
2026-10-13 05:21:04 +07:00
fastmikey99andClaude Opus 5.5 d084af162f Render: decode print-size rasters, and say when an image is missing from an export
The renderer decoded images with the decoder's default 512 MB allocation
limit, so a 300 ppi roll (6614 x 59055 px, 1.56 GB as RGBA) that opens and
exports to PDF fine drew nothing in a raster export - a fully transparent PNG,
exit code 0, no warning. It now decodes with the allowance opening has (2 GB,
512 MB on the web), shrinks an image past a pixmap's 65535 px side to fit
instead of dropping it, and counts any image it still can't decode: a raster
export that drew fewer images than it should says so in its warnings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 05:20:57 +07:00
fastmikey99andClaude Opus 5.5 e01b9a20d9 Crop Image trim: decode print-size rasters, and leave CMYK images alone
Trim decoded the image with the decoder's default 512 MB allocation limit, so
a 300 ppi roll (6614 x 59055 px, 1.56 GB as RGBA) that opens fine failed with
"the image's pixels can't be read". It now decodes with the allowance opening
already has (MAX_RASTER_ALLOC: 2 GB, 512 MB on the web). A CMYK image, kept as
its ink amounts, has no alpha: trim leaves it alone (trimmed: false) instead
of failing to decode it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 05:20:48 +07:00
BFlat bd9b8a2db3 Merge pull request #1511 from Atalantis/fr-complete-catalog
i18n: complete the French (fr) UI catalog (423 entries)
2026-10-13 05:10:38 +07:00
BFlat c67c25128c Merge pull request #1509 from marcoarnulfo/fix/direct-select-active-anchor-precedence
Direct Selection: a selected path's anchor is picked before one of an object above it
2026-10-13 05:10:23 +07:00
BFlat b9e5536794 Merge pull request #1507 from duydev/feat/vietnamese-localization
G16: Vietnamese interface (vi)
2026-10-13 05:10:08 +07:00
BFlat 15e1f6327f Merge pull request #1502 from dotCooCoo/feat/perf-windows-load-memory
CLI: perf on Windows reports the cores busy and its working set, and the help lists --deep
2026-10-13 05:09:52 +07:00
bflatastic 57a4d9c6e1 French: the shortcut warning starts with a word, as in the other catalogs (the UI fonts have no warning sign) 2026-10-13 05:03:42 +07:00
Florent Siegenthaler e87abf02a8 i18n: align French terms with the names vector illustrators know (188 entries)
Rewords French entries to the feature names French-speaking users of vector illustration software know, as the catalog header asks: Repères commentés, Sélection directe progressive, Trait for Line Segment, Déformation for Shear, Associer for Group, Annuler for Release in the Make/Release submenus, Étape de dégradé, Redéfinir les couleurs de l'illustration, the graph and symbolism tool names. 27 terms are attested in Adobe's public French help pages; the others come from the reviewer's knowledge of the French interface. The eight strings pinned by french_reads_as_french and the MENU_KEEP_AS_IS strings are untouched. Keys with two meanings (Trim, Crop, Merge, Outline, Gradient) are left unchanged: they need @id entries.
2026-10-13 05:03:42 +07:00
Marco Arnulfo e4c2b3db94 Direct Selection: a selected path's anchor is picked before one of an object above it (fixes #1508) 2026-10-13 05:03:41 +07:00
bflatastic 2ec817e76a Vietnamese: the 3D dialog's and 3D effects' rows the other complete catalogs have 2026-10-13 05:03:41 +07:00
Florent Siegenthaler 9e39a6d124 i18n: complete the French (fr) UI catalog (423 entries)
Adds the 423 keys that other catalogs translate and fr.tsv lacked. The 19 MENU_KEEP_AS_IS strings stay out of the catalog, as the menu tests require. Entries follow the existing catalog's wording and typography (no-break space before : ; %, typographic apostrophe); placeholders, edge spaces, trailing ellipses and plural forms are preserved.
2026-10-13 05:03:41 +07:00
Trần Nhật Duy ec8dc0c2da i18n: add complete Vietnamese localization 2026-10-13 05:02:43 +07:00
dotCooCoo 22e06db73d CLI: perf on Windows reports the cores busy and its working set, and the help lists --deep
On Windows, perf printed "load average ?": load_average runs sysctl
or reads /proc/loadavg, and Windows has neither. The warning for a
busy machine could not print, so a run over budget on a busy machine
failed, where macOS and Linux warn and exit 0. perf --deep printed no
memory rows, because rss_mb runs ps. --help did not list --deep.

On Windows, perf now reads GetSystemTimes twice, one second apart,
and its first line gives the cores busy on average over that second
where macOS and Linux give the load average. It warns above three
quarters of the cores, as on macOS and Linux: with all 32 logical
processors of a test machine kept busy, the line read "cores busy
32.0" and a run with three budgets over exited 0. The --deep memory
rows give the working set (GetProcessMemoryInfo). Both calls use
winsafe 0.0.29, which Cargo.lock already lists for the desktop app;
the CLI depends on it on Windows with the kernel and psapi features.
The help lists --deep and says that a run over budget on a busy
machine exits 0. perf's report on macOS and Linux does not change.
2026-10-13 05:02:25 +07:00
BFlat aebeb66268 Merge pull request #1520 from storytold/u/trace-docs
Image Trace: progress docs for the palette and photo changes
2026-10-13 04:59:45 +07:00
bflatastic cf137c8c90 Docs: Image Trace's colour and photo presets, measured (ROADMAP log, parity evidence) 2026-10-13 04:42:29 +07:00
BFlat e131dfbb1c Merge pull request #1516 from storytold/u/trace-photo
Image Trace: photos trace into smooth tones (smoothing first, stacked layers by lightness)
2026-10-13 04:42:08 +07:00
bflatastic 77f81c8944 Image Trace: photos are smoothed within their tones first, and stacked layers go by lightness
A photo's noise is as large as the step from one palette colour to the next, so noise decided pixel by pixel
which of two neighbouring colours a pixel got: tens of thousands of specks, or (since the palette change)
neighbouring tones merged into one.

- A continuous-tone image (eight colours leave much of it unsaid; flat art, however noisy or unevenly lit, is
  not one) is smoothed before it is quantised: three rounds of a small bilateral filter, which averages noise
  away within a tone and keeps the edges between tones where and as sharp as they were.
- With Method: Overlapping, layers are stacked by lightness, from the largest colour's end of the scale,
  instead of by area. Each layer reaches under those above it, so where two colours meet every layer between
  them in the stack ends on that same edge and shows through its anti-aliasing: by area that was an unrelated
  colour's hairline around every region; by lightness it is a neighbouring tone.
- Ignore White with Overlapping: no layer reaches under the white that is left out.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- High Fidelity Photo: colour error 2.37 -> 1.75 (2.30 at the baseline), edge error 2.59 -> 1.98, 639 -> 121
  paths (3,653 at the baseline), 25,604 -> 9,818 anchors, specks 152 -> 10; a 16 megapixel photo-like image is
  traced in 3.5 s (31,131 paths) where it was refused
- Low Fidelity Photo: colour error 2.67 -> 2.45, 55 -> 37 paths
- Shades of Gray: 73 -> 17 paths, colour error 9.33 -> 9.26
- The other presets' numbers are unchanged
2026-10-13 04:28:02 +07:00
BFlat 7fadeb4ed2 Merge pull request #1485 from webdevtodayjason/graph-csv-precision
Graph Data: the CSV keeps each value exactly, not rounded to two decimals
2026-10-13 04:06:03 +07:00
BFlat 396d7d2b76 Merge pull request #1487 from itrycodinganditsbad/fix-1458
Tabs panel: drag a tab stop from where the press landed
2026-10-13 04:05:48 +07:00
BFlat e30b1492a1 Merge pull request #1506 from storytold/fix/1483-slider-rails
Fix #1483: sliders in dialogs show their rail
2026-10-13 04:05:31 +07:00
BFlat e6ae1d0df3 Merge pull request #1505 from storytold/fix/1486-template-outline
Fix #1486: a template layer stays in Preview in Outline view
2026-10-13 04:05:18 +07:00
webdevtodayjason 43e1a7380c Graph Data: the CSV keeps each value exactly, not rounded to two decimals 2026-10-13 04:00:18 +07:00
Fabio LussiandClaude Opus 5.5 7ab9a46bf8 Tabs panel: drag a tab stop from where the press landed
egui reports drag_started only once the pointer is past its drag
threshold, farther from the press than the ruler's 5 px hit tolerance,
so hit-testing the current pointer usually missed the stop. Hit-test the
press origin instead, falling back to the pointer position.

Fixes #1458

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 04:00:17 +07:00
bflatastic dd1c26b627 Part of #1483: sliders in dialogs show their rail
The theme's widget fill matches the dialog background, so the sliders of
Save for Web, the raster export options, Preferences and the opacity
popup showed only their handle. They share one slider widget now, with a
visible rail filled up to the handle.
2026-10-13 04:00:17 +07:00
bflatastic a787fb487c Fix #1486: a template layer stays in Preview in Outline view
In Outline view a template layer drew as outlines like any other, so a
picture placed to be traced turned into a box. It now keeps drawing in
Preview (dimmed as before) while the other layers are in outline.
2026-10-13 04:00:17 +07:00
BFlat ce0c86f91a Merge pull request #1504 from storytold/u/trace-palette
Image Trace: colour presets trace flat art with its own colours (no shattering, no slivers)
2026-10-13 03:55:45 +07:00
bflatastic 4204ce36db Image Trace: a palette of the picture's own colours
Color and Grayscale chose their colours from every pixel, so the blends along anti-aliased edges took palette
entries and traced as slivers, and with more colours asked for than the art has, k-means split flat colours
along their noise and the halves traced as specks all over each other.

- The palette is made from the picture's own colours: a pixel that lies between the pixels on opposite sides
  of it is a blend and counts for nothing (lines one pixel wide and noise are no blends).
- Colours that noise made of one colour merge: close colours whose pixels are mixed through each other. The
  bands of a gradient keep to themselves and stay apart.
- A blend pixel whose nearest palette colour is a third colour (halfway from navy to yellow lies an olive that
  is nearer to a green elsewhere) goes to the nearer of the two colours it lies between.
- Boxes are cut where their two sides differ most (Otsu's criterion) instead of at the median, which split the
  largest colour of flat art along its noise and left two smaller colours in one box.
- Colours are compared in Oklab when pixels are assigned and in k-means.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md), before -> after:
- 16 Colors: 69.9 -> 4.2 paths, 1743 -> 66 anchors, outlines 5.08 -> 0.13 px off on average (largest 85.9 ->
  1.09), kinks 1376 -> 4.4, paths in a colour the art doesn't have 5.9 -> 0
- 6 Colors: 21.2 -> 4.0 paths, 4.44 -> 0.14 px off; 3 Colors: colour error 4.86 -> 0.43, 0.33 -> 0.09 px off
- High Fidelity Photo: 3636 -> 639 paths, 81,597 -> 25,604 anchors, colour error 2.50 -> 2.37; a 1 megapixel
  photo-like image 1.98 -> 0.42 s (51,389 -> 4,298 paths), a 4 megapixel one traced (27,583 paths) where it was
  refused
- Low Fidelity Photo: 128 -> 55 paths, colour error 2.59 -> 2.67; Shades of Gray: 91 -> 73 paths, 9.29 -> 9.33
2026-10-13 03:48:30 +07:00
BFlat a891530ef2 Merge pull request #1500 from storytold/fix/1471-text-overflow
Fix #1471: making and restyling type reports text that doesn't fit its frame
2026-10-13 03:44:26 +07:00
BFlat c372585b73 Merge pull request #1501 from storytold/docs/progress-1013a
Docs: ROADMAP 2026-10-12 adds #1430–#1497 (later merges of the day)
2026-10-13 03:43:38 +07:00
bflatastic 8b8fd1476b Fix #1471: making and restyling type reports text that doesn't fit its frame
text.create, text.createInPath, text.setText and text.setStyle answer with
overflow: [id…] for the area type and type on a path whose text doesn't fit (left
out when all fits), and MCP add_text with overflow: true. add_text takes area
type's fit and fitMinPercent.
2026-10-13 03:27:13 +07:00
BFlat 214312f883 Merge pull request #1481 from AyberkA0/fix/3d-curve-sampling
3D and Materials: curves follow a tolerance, and Rotate takes large outlines
2026-10-13 03:27:03 +07:00
BFlat 2c74cd928a Merge pull request #1474 from Skaywalker/i18n-hu-review
i18n(hu): informal address in a row added on merge
2026-10-13 03:26:49 +07:00
bflatastic c1be8b82db Docs: ROADMAP 2026-10-12 adds #1434–#1497 (later merges of the day)
Progress log, evidence, checklist rows and revision history for the
changes merged late on 2026-10-12 that the progress docs didn't have.
2026-10-13 03:25:37 +07:00
bflatastic c3c036daf2 3D and Materials: the edge index's search from outside its grid looks along the side
A point outside the grid can be nearer an edge further along the grid's side than one
straight ahead of it; the search stopped a ring early there. The bucket lookup is one
function.
2026-10-13 03:14:50 +07:00
Skaywalker 905a965d1e i18n(hu): informal address in a row added on merge
One message translated when #1302 was merged addressed the user with
the formal verb form ("csatolja"); the catalog uses the informal second
person throughout ("csatold").
2026-10-13 03:14:50 +07:00
Ayberk A. b3ada24da7 3D and Materials: curves follow a tolerance, and Rotate takes large outlines
Extrude & Bevel, Inflate and Rotate sampled every curve at sixteen points and
refused a shape past 1,024 points, about 64 curves: outlined words reached
that quickly, and Rotate refused them although it only turns the outline.

- Each curve takes as many straight pieces as keep it within 1/2000 of the
  shape's size (at least 0.05 pt), by Wang's bound: 1 to 32.
- Extrude & Bevel and Inflate take up to 2,048 points, Rotate 65,536.
- Inflate's distance and crossing tests use a bucketed edge index, and
  cells no edge crosses skip clipping: about 80 ms at 2,000 points (it was
  over 600 ms with the old search; 95 ms at the old 1,024-point limit).

Revolve's own sampling is unchanged.
2026-10-13 03:14:50 +07:00
BFlat 1eb9b34439 Merge pull request #1497 from storytold/w/1457-field-follows-value
Fix #1457: a focused field follows its value when a slider or preview changes it
2026-10-13 03:11:54 +07:00
BFlat b49484af08 Merge pull request #1496 from storytold/w/1442-unknown-params
Fix #1442: an unknown parameter key is an error naming it and the nearest accepted key
2026-10-13 03:11:44 +07:00
BFlat 908b4ddcbc Merge pull request #1495 from storytold/w/1459-surface-limit
Part of #1459: the window's surface may be as large as the graphics adapter allows
2026-10-13 03:11:34 +07:00
BFlat fcc52c3c7b Merge pull request #1494 from storytold/w/1448-transform-objects
Fix #1448, #1460: Transform Objects in the Transform dialogs and Transform Each, and Reflect across an angle
2026-10-13 03:11:24 +07:00
BFlat 499cd4f30c Merge pull request #1493 from storytold/w/1450-pathfinder-group
Fix #1450: the Pathfinder panel works on a group selected alone
2026-10-13 03:11:14 +07:00
bflatastic 3672e0ae84 Fix #1457: a focused field follows its value when a slider or preview changes it 2026-10-13 02:54:33 +07:00
bflatastic 3acc29f8e0 Fix #1442: an unknown parameter key is an error naming it and the nearest accepted key 2026-10-13 02:54:33 +07:00
bflatastic ff6e136b97 Part of #1459: the window's surface may be as large as the graphics adapter allows 2026-10-13 02:54:08 +07:00
bflatastic 897d99a70f Fix #1448, #1460: Transform Objects in the Transform dialogs and Transform Each, and Reflect across an angle 2026-10-13 02:54:08 +07:00
bflatastic 7625feea08 Fix #1450: the Pathfinder panel works on a group selected alone 2026-10-13 02:54:08 +07:00
BFlat e74cb01ecc Merge pull request #1490 from storytold/fix/1465-selected-text
Part of #1465: selected text in value fields is readable
2026-10-13 02:49:42 +07:00
BFlat 67af0cdf82 Merge pull request #1489 from storytold/fix/1466-gray-alpha-tiff
Part of #1466: grayscale TIFFs with an alpha channel or extra channels can be placed
2026-10-13 02:49:31 +07:00
BFlat 3dbb551db3 Merge pull request #1488 from storytold/fix/1462-split-into-grid
Fix #1462: Split Into Grid has its row and column sizes, gutters, totals, Add Guides and Preview
2026-10-13 02:49:20 +07:00
bflatastic 09f62097f0 Part of #1465: selected text in value fields is readable
Selected text in a numeric or text field was drawn in the accent colour on the accent-tinted
selection, in every brightness. The fields that draw their own frame (panel and Control bar fields,
dialog fields, the hex field) now draw it in the strong text colour.
2026-10-13 02:48:41 +07:00
bflatastic 81a568f4c6 Part of #1466: grayscale TIFFs with an alpha channel or extra channels can be placed
File > Place, Relink and Open refused a grayscale TIFF whose pixels carry an alpha channel or
another extra channel: the image decoder reads neither. They are now read like CMYK TIFFs with
alpha: the first extra sample is the transparency when the file says it is alpha (associated alpha
divided back out), and other channels are left out.
2026-10-13 02:48:39 +07:00
bflatastic cb9d93b647 Fix #1462: Split Into Grid has its row and column sizes, gutters, totals, Add Guides and Preview
Object > Path > Split Into Grid took a number of rows and columns and one gutter, and split each
selected path on its own. The dialog now has the documented options, the rows beside the columns:
number, height or width, gutter and total, with Add Guides and Preview. Several selected paths
become one grid over them all, painted as the topmost one, as documented.

object.path.splitIntoGrid takes rowGutter and columnGutter (gutter still sets both), totalHeight
and totalWidth, height and width (the size of each row and column, which sets the total) and
addGuides, which adds ruler guides along the row and column edges on the grid's layer.

Behaviour reference:
https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html
2026-10-13 02:48:36 +07:00
BFlat 974325b62a Merge pull request #1464 from dotCooCoo/feat/alt-drag-copies-once
Part of #1429: Alt-drags with the selection and transform tools copy the objects once per drag
2026-10-13 02:48:23 +07:00
BFlat 3a8e1ec0f8 Merge pull request #1463 from webdevtodayjason/fix/plugin-default-range
Plug-ins: numeric defaults stay in their declared range
2026-10-13 02:48:07 +07:00
BFlat 05667c40b1 Merge pull request #1461 from Losminek/cs-sync
i18n(cs): catch up with strings added since the Czech catalog
2026-10-13 02:47:52 +07:00
dotCooCoo 571289ddb6 Part of #1429: Alt-drags with the selection and transform tools copy the objects once per drag
On each pointer move of an Alt-drag with the Selection, Direct
Selection, Group Selection, Rotate, Scale, Reflect or Shear tool,
Session::preview put the drag's snapshot back and ran object.transform
with copy again, and duplicate_in copied every selected object again.
The Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview
on copied every selected object again on each change of a value.

The interaction now keeps the untransformed copies that its first
preview with copy makes of the snapshot. Each later preview that
copies the same objects of the same snapshot transforms only those
copies. The copies keep their ids, and the document, the selection,
the undo step and the journal are the same as before. With 25,000
rectangles selected, a release build of vectorcraft-cli took a median
of 138.5 ms for each Alt-drag move after the first preview and takes
105.7 ms with this change.

Docs: docs/target-app-parity.md.
2026-10-13 02:21:35 +07:00
webdevtodayjason 08c8c11798 Plug-ins: numeric defaults stay in their declared range 2026-10-13 02:21:35 +07:00
LosminekandClaude Opus 5.5 847730bf8b i18n(cs): catch up with strings added since the Czech catalog
Adds 127 Czech entries the Hungarian/Spanish/German catalogs already
have: tool short names, recolor and swatch library labels, print/PDF
preset names and their differences lines, pie graph options, counts
and the about-box credits. "Reset <workspace>" entries keep the
built-in workspace names in English as MENU_KEEP_AS_IS requires.
Also [Default] -> [Výchozí] in the protected-preset hint, matching the
existing [Default] entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 02:21:35 +07:00
BFlat 0d8b08ccd4 Merge pull request #1478 from storytold/u/trace-subpixel
Image Trace: edges to a fraction of a pixel in Black and White, Grayscale and Color
2026-10-13 02:06:03 +07:00
bflatastic fcd7e3b42d Image Trace: edges to a fraction of a pixel in Black and White, Grayscale and Color
Each crack between an inside and an outside pixel is moved to where the pixels across it say the edge is (the
blend of the two tones in the column of pixels across the crack; a thin line by the centroid and sum of its
tone; hard-edged images are read as staircases), and the outline is fitted as Flat Logo mode fits its own:
corners put back, straight runs as lines, a round outline as a circle, the rest as few curves as Paths allows.
Snap Curves To Lines straightens slightly curved runs and makes arcs. A fit that strays from its outline
gives way to a plainer one, and at last to the outline's own points.

On each preset's own inputs, all sizes and variants (docs/image-trace-quality.md):
- Default: outlines 0.313 -> 0.091 px off on average, largest 1.51 -> 1.02; edge error 17.2 -> 10.1;
  gaps between abutting paths 63.5 -> 22.2 px, overlaps 67.0 -> 22.4 px
- Black and White Logo: 0.270 -> 0.064 px off, kinks 15.3 -> 0.3, anchors 3.78 -> 2.73 times the art's
- Technical Drawing: 0.201 -> 0.086 px off; Line Art: 0.158 -> 0.093
- Worse: anchors on blurred and scanned thin lines (Line Art 25 -> 77 on average), High Fidelity Photo
  (colour error 2.30 -> 2.50, anchors 46,866 -> 81,597, 1 megapixel photo-like image 0.37 -> 1.98 s)
2026-10-13 01:55:17 +07:00
bflatastic a17e3057de Image Trace quality harness: an aliased variant, and every drawing can be written out 2026-10-13 01:45:57 +07:00
BFlat 82ac5c92ea Merge pull request #1453 from AyberkA0/feat/3d-rotate-extrude-inflate
Effect › 3D and Materials: Extrude & Bevel, Inflate and Rotate
2026-10-13 01:38:27 +07:00
BFlat 7718b62735 Merge pull request #1452 from dotCooCoo/feat/raster-effects-follow-document-edits
Render: raster effects are made again when patterns, symbols, images, settings or fonts change
2026-10-13 01:38:14 +07:00
BFlat 63e0bff91d Merge pull request #1451 from dotCooCoo/feat/text-frames-huge-coordinates
Type: frames and paths beyond 1,000,000,000 pt hold no text, and layout limits its search for room
2026-10-13 01:37:59 +07:00
BFlat e2177c0d3a Merge pull request #1445 from giang17/fix-fitcheck
Fix #1444: text.fitHeadline sees wrapping in a frame one line tall
2026-10-13 01:37:37 +07:00
bflatastic 2cf2b972f5 3D and Materials: the bevel and rotation options take their documented names, and the new messages are in every catalog
Bevel Shape, Space, Bevel Inside, Bevel both sides and Presets, as the public 3D and Materials panel
options page names them. The 3D effects' error messages get rows in the twelve complete catalogs.
2026-10-13 00:52:40 +07:00
Ayberk A. 8a3458a974 Effect › 3D and Materials: Extrude & Bevel, Inflate and Rotate
The three 3D types besides Revolve, as live effects in the three-d crate,
drawn as shaded vector faces by the canvas, SVG/PDF export and Expand
Appearance alike, from the public 3D and Materials documentation:

- Extrude & Bevel: depth (0-2000 pt), twist, taper, solid or hollow caps,
  and bevels in six original cross-sections (classic, rounded, cove, step,
  ogee) with width, height, repeat, spacing, inside and both sides.
- Inflate: the shape as a membrane under pressure (a Poisson solve held at
  zero along the outline and holes), so it rises vertically from the edge
  and rounds without creases; volume, depth and both sides. Steep cells
  near the outline are refined without cracks.
- Rotate: every fill and stroke turned in 3D and shaded as one plane.

All three share Revolve's view and lighting parameters, the rotation and
light gizmos (turning about the art's centre) and a dialog with Object,
Rotation and Lighting tabs, with Position presets (Front ... Isometric
Bottom, our own values). An object takes one 3D effect. Inflate cuts the
grid cells that straddle a hole's edge with pathops, hence the new
pathops -> three-d layer order.

Docs: docs/three-d.md, mcp.md, and the 3D rows of the parity docs.
2026-10-13 00:35:07 +07:00
dotCooCoo f4e72fe6a5 Render: raster effects are made again when patterns, symbols, images, settings or fonts change
The renderer kept the rasters of an object's drop shadows, outer glows
and filtered content (Gaussian Blur, Feather and the Photoshop-style
filters), and drew them again while the object, the zoom and rotation
and the ink plane stayed the same. The content drawn into a raster also
reads things outside the object: patterns, symbols, brushes, images,
document, layer and color settings, the knockout a group inherits, the
fonts, and the options the frame is drawn with. When one of them
changed, a renderer that had drawn the object went on drawing the
raster it had kept, made from the old values.

Each kept raster now records what its content was drawn from: which of
those things the object reads, found by walking the object when the
raster is made, and a hash of their values then. The renderer draws
the raster only while that hash is the same in the current frame. Once
a frame, the renderer hashes the document's patterns, symbols and
images. It hashes their art and pixels by the addresses of their
shared values, and it keeps weak references to those values while a
raster made with them is kept, so no other value can take one of those
addresses. A filtered raster also records the color channels its
filters split, and under De-Interlace the raster resolution.

A temporary release test timed the inputs of 400 kept rasters at about
10 microseconds a frame.
2026-10-13 00:34:41 +07:00
dotCooCoo 8754646784 Type: frames and paths beyond 1,000,000,000 pt hold no text, and layout limits its search for room
Opening an EPS whose threaded area type frame has a control point at
x = 8e115 failed with an internal error ("capacity overflow").
`eps_text_frames_never_panic` in `import_fuzz` found it with
PROPTEST_CASES=20000. Text layout flattens a frame to within 0.1 pt,
and for such a curve kurbo 0.13.1's `flatten` calls `reserve` for
`usize::MAX` quadratic pieces. Laying out text around a wrap shape with
such a curve failed the same way. Laying out type on a path along such
a curve panicked in kurbo's `solve_itp` ("attempt to shift left with
overflow") in builds with overflow checks, and in a release build it
did not finish within 20 s. Laying out area type in a triangle frame
that comes to a point at its top, at y = 1e20, did not finish either:
adding a lead to a baseline that large leaves the baseline where it is.

Text layout now leaves out a frame, a text wrap shape or the path of
type on a path that has a coordinate beyond 1,000,000,000 pt either
way, control points included, or a NaN coordinate in an anchor point.
Such a frame or path holds no text, and such a wrap shape is ignored.
PDF export uses the same bound (`MAX_COORD` in
`crates/pdf/src/export.rs`).

Text layout also steps a line down a frame cell at most 100,000 times
to find room for it. In a cell taller than 100,000 leads, a step is the
cell's height divided by 100,000; in a shorter cell, a step is one
lead, as before. Before this change, in a dev build, with triangle
frames that come to a point at their top, laying out text in one
2,000,000,000 pt tall took 9.8 s, and laying out a paragraph whose
Space Before is -1e300 pt in one 1,000 pt tall did not finish within
20 s.
2026-10-13 00:34:40 +07:00
Giang Nguyen 2b34554ac8 fix(text): fitHeadline sees wrapping in a frame one line tall
The layout drops lines that overflow an area frame vertically, so counting
the first paragraph's laid-out lines cannot see a wrapped second line
when the frame holds only one. The bisection then found every tracking
"one line" and ran to its 2000 ceiling, which spread the line far past the
frame (most glyphs clipped away) while the command reported success.

one_line now asks whether the first line reaches the end of the first
paragraph, which holds regardless of dropped overflow lines. Regression
test: area 240x50 at size 36 previously returned tracking 1999.9, now
fills the width (avail - w < 5) with the paragraph on one line.
2026-10-13 00:34:40 +07:00
BFlat 9f0a91b638 Merge pull request #1449 from storytold/fix/1423-affinity-short-frame
Fix #1423: an Affinity text frame too short for its first line is made taller instead of showing nothing
2026-10-13 00:30:41 +07:00
BFlat f2c7c17eb1 Merge pull request #1455 from storytold/u/trace-harness
Image Trace: a quality harness and the baseline for every preset
2026-10-13 00:12:25 +07:00
bflatastic 182a2cda4b Image Trace: a quality harness and the baseline numbers for every preset
crates/trace/tests/quality draws its inputs from known vector art (no image files), traces them with every
built-in preset and measures fidelity, distance from the true outlines, economy, artefacts, time and memory.
docs/image-trace-quality.md records the numbers on main before any change, beside the same measures of an
open tracer run as a black box. No change to the tracer.
2026-10-12 23:56:36 +07:00
BFlat 80af380a6a Merge pull request #1430 from dotCooCoo/feat/many-object-edits-one-pass
Part of #1429: Edit and Object commands on many objects take time linear in the document
2026-10-12 23:52:06 +07:00
BFlat 9251edf1dc Merge pull request #1447 from storytold/docs/progress-1012g
Docs: ROADMAP 2026-10-12 adds #1397–#1428 (later merges of the day)
2026-10-12 23:51:48 +07:00
bflatastic 988f52ecc8 Fix #1423: an Affinity text frame too short for its first line is made taller instead of showing nothing 2026-10-12 23:42:56 +07:00
dotCooCoo ffa948dcc3 Part of #1429: Edit and Object commands on many objects take time linear in the document
Delete, Cut, Group, Make Compound Path, Make Clipping Mask, Send to
Current Layer, Ungroup and Release Compound Path removed, inserted or
moved one object at a time. duplicate_in inserted one copy at a time;
Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs,
Transform Again after a copy, Transform Each with Copy, the perspective
copies and each move of an Alt-drag call it. Each insertion or removal
moved the later objects of the same layer or group to other indices.
The next lookup by id then walked the tree, and after every fourth such
walk Document::index_path rebuilt the whole lookup cache. Each command
did work that grew with the number of objects times the size of the
document: with 25,000 rectangles selected, a release build of
vectorcraft-cli took 7.2 s to group them and 11.5 s to copy them with
object.transform.

Document::remove_many, Document::wrap and Document::insert_beside
remove nodes, wrap them in a new node, or put nodes directly above or
in place of their anchors, in one pass over the children of each
parent. Each finds the nodes with one index_paths call, checks every id
before it changes anything, and clears the lookup cache once. The
commands above use them. The objects keep their order, the copies take
their ids in the same order, and the selection, the undo step and the
journal are the same. duplicate_in still copies one root at a time when
a root is listed twice or lies inside another root. A release build
with this change groups the 25,000 rectangles in 27 ms and copies them
in 121 ms.

Docs: docs/target-app-parity.md.
2026-10-12 23:42:43 +07:00
bflatastic 25c33b6927 Docs: ROADMAP 2026-10-12 adds #1397, #1402, #1404, #1409, #1413–#1415, #1418, #1426–#1428 2026-10-12 23:42:39 +07:00
BFlat baf9693f0f Merge pull request #1440 from EricKuck/feat/ai-open-from-editing-data
.ai import: editingData "always" and convert --editing-data open a file from its editing data even where its page draws differently
2026-10-12 23:29:22 +07:00
BFlat e01a0a70be Merge pull request #1439 from EricKuck/feat/ai-editing-data-art-styles
.ai import: art styles, a clipping path's own fill, compound paths of looks and a group's own transparency from the editing data
2026-10-12 23:29:06 +07:00
BFlat 911c65de34 Merge pull request #1438 from EricKuck/feat/ai-editing-data-gradients-meshes
.ai import: radial and skewed gradient matrices, focal points and stop order, gradient meshes and artboard names from the editing data
2026-10-12 23:28:49 +07:00
BFlat 83d271fb89 Merge pull request #1435 from EricKuck/feat/svg-knockout-in-order
SVG export: knockout groups of opaque art draw in order, without masks
2026-10-12 23:28:36 +07:00
BFlat 8811cc552e Merge pull request #1434 from EricKuck/fix/pdf-import-group-fill-stroke
PDF import: an editor's .ai keeps its RGB numbers, and a fill faded or masked by its group keeps its stroke apart
2026-10-12 23:28:19 +07:00
bflatastic 9e4ed2d430 Catalog rows for the trusted editing data note; the fuzz reads hostile editing data trusted too 2026-10-12 23:08:58 +07:00
EricKuck 42c923d62a .ai import: editingData "always" and convert --editing-data open a file from its editing data even where its page draws differently 2026-10-12 23:07:25 +07:00
EricKuck 939bc02f6b .ai import: art styles, a clipping path's own fill, compound paths of looks and a group's own transparency from the editing data 2026-10-12 23:07:05 +07:00
EricKuck 77120ada62 .ai import: radial and skewed gradient matrices, focal points and stop order, gradient meshes and artboard names from the editing data 2026-10-12 23:06:51 +07:00
EricKuck 92f117cf1d vectorcraft-cli: --decimals sets the SVG coordinate precision 2026-10-12 23:06:36 +07:00
EricKuck 5b413448d9 SVG export: knockout groups of opaque art draw in order, without masks 2026-10-12 23:06:34 +07:00
EricKuck 34b3c59a03 PDF import: an editor's .ai keeps its RGB numbers, and a fill faded or masked by its group keeps its stroke apart 2026-10-12 23:06:19 +07:00
BFlat 04bc43c802 Merge pull request #1428 from storytold/q/1412-transform-dialog-preview
Fix #1412: the Transform dialogs preview on the canvas, and arrow keys step their angles and percentages
2026-10-12 21:10:53 +07:00
BFlat 57e7cd3b0e Merge pull request #1427 from storytold/q/1408-transform-anchors
Fix #1408: the Transform tools and commands act on the anchors selected with Direct Selection
2026-10-12 21:10:37 +07:00
BFlat 487cc29b57 Merge pull request #1426 from storytold/q/1410-ai-compound-paint
Fix #1410: an .ai compound path takes its last path's paint, and paths grouped in it stay its paths
2026-10-12 21:10:21 +07:00
bflatastic 4d17702536 Fix #1412: the Transform dialogs preview on the canvas, and arrow keys step their angles and percentages 2026-10-12 21:01:15 +07:00
bflatastic a41d59604b Fix #1408: the Transform tools and commands act on the anchors selected with Direct Selection 2026-10-12 21:01:15 +07:00
bflatastic 003234a056 Fix #1410: an .ai compound path takes its last path's paint, and paths grouped in it stay its paths 2026-10-12 21:01:15 +07:00
BFlat c70bd33de0 Merge pull request #1418 from Messhias/docs/1417-mcp-guide-sync
Docs: sync mcp.md with acting prefs and shipped command families
2026-10-12 20:50:24 +07:00
bflatastic 3da2685065 Docs: correct the hyphenation exceptions and appendConverted notes in mcp.md (#1417) 2026-10-12 20:40:44 +07:00
Fabio William Conceição 2c508ea867 Docs: sync mcp.md with acting prefs and shipped command families (#1417). 2026-10-12 20:39:57 +07:00
BFlat 54afb67b4f Merge pull request #1409 from vbasky/fix/1396-fat-stroke
Fix #1396: a stroke wider than its closed shape paints solid
2026-10-12 20:36:00 +07:00
BFlat 22f14bec24 Merge pull request #1404 from vbasky/fix/1385-paste-picture
Paste the picture when the clipboard text beside it is blank.
2026-10-12 20:22:21 +07:00
bflatastic 1168c319b0 Close the hole whenever the inner offset of a closed stroke has crossed over
The check added for #1396 dropped the inner contour only when the path itself fell in the hole, which takes a stroke more than twice as wide as the shape. Between one and two times the size (a 30 pt ellipse with a 50 pt stroke), and on rectangles with round or bevel joins, the hole stayed.

An offset contour is now left out when no point of it lies half the weight from its subpath: it is then nowhere the edge of the stroke. Subpaths are judged one by one, and only when the stroke is at least as wide as the subpath's bounds, so ordinary strokes take the same single call as before.
2026-10-12 20:22:07 +07:00
BFlat c26ff9a22a Merge pull request #1402 from vbasky/fix/1229-corner-bounds
Hide the selection bounds while a corner-radius drag is underway.
2026-10-12 20:22:04 +07:00
Vikram Bhaskaran aed5d380e9 Cover a small rectangle in the fat-stroke test. 2026-10-12 20:09:53 +07:00
Vikram Bhaskaran b4c1cdbcea Fill the hole a stroke leaves when it is wider than the closed shape.
A closed path stroked wider than it is came back as a ring, because the inner offset crossed the middle. When that outline no longer covers the path, the outer contour is kept so the stroke paints solid.
2026-10-12 20:09:53 +07:00
BFlat 2a0a279c93 Merge pull request #1415 from storytold/fix/isolate-missing-id
object.isolate: an id that doesn't exist says so
2026-10-12 20:06:24 +07:00
bflatastic ec38b00a22 object.isolate: an id that doesn't exist is reported as such 2026-10-12 20:06:04 +07:00
BFlat d6bace14c3 Merge pull request #1397 from vbasky/fix/1389-inspect-paint
Report compound-path paint and a non-normal blend in document summaries.
2026-10-12 20:05:59 +07:00
bflatastic a366e8ee0d Part of #1389: a compound path's members say so instead of claiming no paint; docs 2026-10-12 20:05:34 +07:00
bflatastic e72a010e1c Merge remote-tracking branch 'origin/main' into K-1397ff 2026-10-12 20:05:33 +07:00
BFlat 92ed381625 Merge pull request #1414 from storytold/fix/1387-ai-text-only-page
Fix #1387: a .ai whose PDF part draws only text opens from it
2026-10-12 20:04:59 +07:00
BFlat 772f056f9c Merge pull request #1413 from storytold/fix/1388-pdf-import-overprint
Fix #1388: PDF and .ai import and file.place keep overprint
2026-10-12 20:04:39 +07:00
bflatastic 7f5d8a3db1 Fix #1387: a .ai whose PDF part is only text opens from it 2026-10-12 19:50:53 +07:00
bflatastic bfb173e967 Fix #1388: PDF import keeps overprint 2026-10-12 19:50:53 +07:00
Vikram Bhaskaran f908545204 Paste the picture when the clipboard text beside it is blank.
Hosts put whitespace in the text flavour ahead of the bitmap, and that empty text was taken as the paste.
2026-10-12 19:50:12 +07:00
Vikram Bhaskaran bf2b5643dc Hide the selection bounds while a corner-radius drag is underway.
The sharp outer box stayed on screen because a corner drag was not treated as a transform.
2026-10-12 19:50:12 +07:00
BFlat 5e4fc4e3af Merge pull request #1405 from storytold/docs/progress-1012f
Docs: ROADMAP 2026-10-12 adds #1375–#1395 (community PRs of the day)
2026-10-12 19:45:30 +07:00
Vikram Bhaskaran 1b79120568 Report compound-path paint and a non-normal blend in document summaries.
document.inspect skipped fill and stroke on compound paths because they are containers, and it never wrote the blend mode.
2026-10-12 23:41:01 +11:00
bflatastic 106925c74b Docs: ROADMAP 2026-10-12 adds #1375, #1376, #1379, #1382, #1383, #1386, #1390–#1392, #1395 2026-10-12 19:36:41 +07:00
BFlat e22a11eda8 Merge pull request #1395 from storytold/dialog-focus-first-enabled
A just-opened dialog gives its focus to the first enabled field, not a greyed-out one
2026-10-12 19:30:53 +07:00
bflatastic 689ad46999 A just-opened dialog gives its focus to the first enabled field, not a greyed-out one 2026-10-12 19:30:20 +07:00
BFlat 2ff64993eb Merge pull request #1394 from storytold/grid-tool-options
Part of #1377: Rectangular Grid and Polar Grid tool options (skew, frame, compound path, fill)
2026-10-12 19:30:16 +07:00
bflatastic ddebe5e479 Part of #1377: Rectangular Grid and Polar Grid tool options (skew, frame, compound path, fill) 2026-10-12 19:22:36 +07:00
BFlat dc640f3d61 Merge pull request #1393 from AyberkA0/feat/double-click-isolate
Isolation mode: double-clicking a single object isolates it
2026-10-12 19:19:47 +07:00
BFlat ce55aaf2fe Merge pull request #1386 from dotCooCoo/feat/symbol-instance-render-cache
Render: the placed art of symbol instances and inline graphics is kept between frames
2026-10-12 19:19:04 +07:00
BFlat 718751b658 Merge pull request #1384 from dotCooCoo/fix/issue-1257
Fix #1257: a dragged object reuses its filter and shadow rasters
2026-10-12 19:18:45 +07:00
BFlat 28e694623d Merge pull request #1383 from Nael-Nathanael/fix/control-port-1373
Fix #1373: --control without a valid port stops the desktop before it starts
2026-10-12 19:18:30 +07:00
Ayberk A. 3073f0ab03 Isolation mode: double-clicking a single object isolates it
With Double Click To Isolate on, the Selection tool isolated groups only. A
double-click on a single path, compound path, image, mesh or placed file now
isolates it too, as the public documentation describes. Type still switches to
the Type tool; blends, envelopes, repeats and symbol instances keep their own
editing modes.

While a single object is isolated only it is hit, marqueed and taken by Select
All, new art goes beside it in its group or layer, and a double-click on the
empty canvas leaves with it selected. object.isolate accepts any object except
type.
2026-10-12 18:56:41 +07:00
dotCooCoo 412e83c825 Render: the placed art of symbol instances and inline graphics is kept between frames
The renderer drew each symbol instance by copying its symbol's art,
staining the copy for a stained instance and moving every object in it
by the instance's transform, in every frame. The copies were new
nodes, so the caches the renderer keys by a node's address (cull
bounds, path geometry, glyph layouts, live-effect geometry) missed for
every object of every instance, and the copies stayed in those caches
until they were pruned. Stroke outlines missed as well, because their
cache uses an entry only when the entry holds the node being drawn.
The renderer copied and moved the art of inline graphics in type the
same way.

The renderer now keeps the art it places in a frame cache with a
64 MiB budget, under the address of the symbol's art, the transform,
whether strokes scale with it, and the instance's stain. When the art
is a group or a layer without an appearance of its own, the renderer
copies none of it in a frame whose instances and symbols did not
change, and the objects in it hit the caches keyed by their own
address (cull bounds and paths, glyph layouts, live-effect geometry).
The stroke-outline cache keys a stroke by its address in the
appearance. In the art placed for an unstained instance, each object
without a gradient fill or stroke shares its appearance with the same
object in the symbol's art. When a symbol has two or more unstained
instances, the renderer therefore still outlines the strokes of those
objects again for each instance in every frame. Moving or restaining
an instance, or editing its symbol's art, places that art again, and
the other instances keep theirs. Other art is placed again in every
frame, as before, and so is art that holds a symbol instance, type
with an inline graphic, an image, a placed document, a blend, an
envelope, a repeat or a raster effect. Art that holds type is placed
again when the fonts change.

vectorcraft-cli bench of a symbol of eight paths (three filled and
stroked, two only filled, three only stroked) shown by 2,000
unstained instances took 51.6 ms a frame on main and 32.3 ms with
this change, at 2880x1800 on 8 threads; the same paths in 2,000
groups took 26.0 ms. The peak working set of a bench run at that size
went from 150 MiB to 83 MiB. In the tests, a renderer that keeps
placed art draws the same pixels as a new renderer, and instances
draw the same pixels as groups of their placed art.
2026-10-12 18:56:39 +07:00
dotCooCoo a9a7a4cddf Fix #1257: a dragged object reuses its filter and shadow rasters
Each pointer move of a drag applies the move to the interaction's
snapshot, so the dragged object is a new node in every frame. The
renderer kept the rasters of an object's drop shadows, outer glows and
filtered content (Gaussian Blur, Feather and the Photoshop-style
filters) only for the same node, so every frame of a drag ran those
effects again. On main, a moved frame of texture_drag_perf (a CMYK
rounded rectangle at 190.07% and DPI 1.5 in an 1800 x 1500 view) took
112 to 197 ms in a release build.

The canvas now renders with RenderOptions::interacting while a drag or
a dialog's preview is in progress. When an object misses its cached
raster in such a frame, the renderer takes the raster an earlier frame
made for an object with the same id, slot and ink at the same zoom and
rotation, made by the same effects from content of the same size (to
within 0.000001 pt). It draws the object's content into a raster
placed as that one was and compares the pixels byte for byte (only the
alpha under a shadow or glow). For Feather it also compares the points
of the clip outline, to within 0.000001 pt. When they match, the old
raster is drawn at the new place, rounded to a whole pixel, as a pan
draws a cached raster. Content under De-Interlace never takes a moved
raster, and a raster made over moved rasters counts as moved itself.

A frame rendered without interacting never draws such a raster, or a
raster made over one: the frame after the drag runs the effects again
and draws what a fresh renderer draws. The canvas's cache key includes
whether an interaction is in progress. On the desktop, the canvas
sends the frame after an interaction to the render worker when the
frame shown drew moved rasters. Each cached raster keeps the content it
was made from, within the existing 512 MiB budget. The pixels kept
with a filtered raster take as many bytes as the raster, and the alpha
kept with a shadow or glow takes a quarter as many as the shadow.

texture_drag_perf now renders a drag's frames as the canvas does, then
the frame after the drag, and that frame once more with every raster
cached. A moved frame now takes 20 to 26 ms, close to the 18 to 25 ms
of the cached frame; the frame after the drag takes 110 to 189 ms.

Docs: docs/target-app-parity.md.
2026-10-12 18:56:38 +07:00
RealtaNua de777a1b09 Contributors: Nael-Nathanael's entry (GitHub profile name) 2026-10-12 18:56:36 +07:00
RealtaNuaandClaude Opus 5.5 4744c2f04b Fix #1373: --control without a valid port stops the desktop before it starts
--control took the next argument as its port and dropped it when it wasn't a number, so the app
started without a control server and `--control --automation-read-root DIR` opened DIR as a
document. automation_args::ControlArg now reads --control <port> or --control=<port> once and
requires a port from 1 to 65535; VECTORCRAFT_CONTROL_PORT must be one too, checked only when
--control isn't given so --control and --version win over a bad value. Errors go through the
same exit as the root flags (#1157).

Tests: unit tests in automation_args.rs; tests/automation_args.rs runs the binary for each bad
form and checks the exact message and a prompt failing exit.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 18:56:36 +07:00
BFlat 155d921fe7 Merge pull request #1392 from storytold/fix/1264-ai-area-type
.ai import: area type shows its first line in a substituted font (follow-up to #1264)
2026-10-12 18:52:30 +07:00
bflatastic 0cf59a6cbd .ai import: area type shows its first line in a substituted font (follow-up to #1264)
Area type whose frame is too short for a line of the font it shows in here
drew nothing: a font that isn't available shows in the fallback font, whose
line may be taller than a frame made for one line of the file's font (overset
type isn't drawn). Where the file itself lays lines out in the frame, the
frame of the imported text object is made taller, from its top, by what its
first line needs, and the open result says how many frames were.
2026-10-12 18:52:09 +07:00
BFlat 46131bd091 Merge pull request #1391 from storytold/fix/1264-ai-labels
.ai import: type goes to its own text object across looks, hidden layers and artboards (follow-up to #1264)
2026-10-12 18:51:55 +07:00
BFlat b185fbbe45 Merge pull request #1390 from storytold/fix/1264-ai-gradient
.ai import: a gradient with stops in the middle runs its whole ramp (follow-up to #1264)
2026-10-12 18:51:49 +07:00
bflatastic 8e16c8529f .ai import: type of text objects with an appearance, on hidden layers and on later artboards goes where it belongs (follow-up to #1264)
- The type in a text object's drawn look (1 /FreeUndo) is a story of the
  undo-free text document, which counts its stories on its own. It was read
  as the story of that number in the text document, so a label could show
  another object's text in another object's frame. The object itself, which
  follows the look, is the type now.
- The page's type of a text object on a hidden layer stays with that object
  (hidden) instead of going to the nearest text object that shows.
- The type on the page of a later artboard is matched where the layers have
  that artboard, not by the first page's place.
2026-10-12 18:42:18 +07:00
bflatastic 8ce63aa1d1 .ai import: a gradient with stops in the middle runs its whole ramp (follow-up to #1264)
The editing data places a gradient with matrices after Bg: Xm for a linear
gradient's whole ramp and one Bm for each pair of stops next to each other.
The reader kept the last Bm, so a gradient of three stops drew only its last
part. It now uses Xm (or, without one, the span of the Bm's from the first
stop to the last), and a radial gradient's one Bm as before. The angle and
length are in the matrices, so Bg's own numbers are used only without them.

An object's own note of its gradient after it (Bb, Bg, BB with no path) no
longer becomes the fill of the next path.
2026-10-12 18:42:16 +07:00
BFlat 885a6e38cd Merge pull request #1381 from storytold/fix/1264-ai-placed
Fix #1264: placed (linked) files in a .ai file's editing data come in as linked images
2026-10-12 18:05:00 +07:00
BFlat ab50796a27 Merge pull request #1382 from storytold/fix/1367-dialog-refocus
Fix #1367: a dialog opened from a menu takes the keyboard focus every time
2026-10-12 18:02:07 +07:00
bflatastic 0765bc9bcd Fix #1367: a dialog opened from a menu takes the keyboard focus every time
A dialog opened by a click is drawn in the frame of that click, and in that
frame egui takes the focus from any field the click missed. The first time a
dialog's window shows, that frame only measures it, so its first field took
the focus a frame later and kept it; every later time the field took it in
the click's frame and lost it at once. The field now takes it the frame after
the click.
2026-10-12 17:48:09 +07:00
bflatastic 39c14abd35 Fix #1264: placed (linked) files in a .ai file's editing data come in as linked images
A .ai file with placed files opened from its PDF part (no guides, no sublayers,
no hidden objects), because the editing-data reader stopped at them.

- Lexer: a placed file's preview section is one token (it was skipped).
- Reader: %AI5_BeginPlace ... ~ becomes a linked image with the file's path,
  showing the preview the data keeps of it (premultiplied ARGB, capped at
  MAX_PIXELS, its rows checked against the bytes there), in its place: the
  matrix takes the file's pixels from the canvas, where they start at the point
  of art space's origin. A placed file without a preview, a path or a matrix
  still makes the file open from its PDF part.
- The open result lists one warning saying linked pictures show the
  low-resolution preview until their files are found (rows in the catalogs).
- Engine: a link nothing is known of whose image shows a preview is read where
  it is found as the document opens, into the image's bounds, and isn't
  reported as modified.
- Tests: lexer, reader (hand-written data), engine open with the link missing
  and found, fuzz tokens.
- Docs: file-format-parity, target-app-parity, gaps, ROADMAP.
2026-10-12 17:46:01 +07:00
BFlat 0a357c68a9 Merge pull request #1379 from storytold/fix/pdf-editing-data-bleed
PDF import: a PDF saved with its editing data opens on its artboard with its bleed
2026-10-12 17:29:01 +07:00
BFlat 57598c067d Merge pull request #1378 from storytold/docs/progress-1012e
Docs: ROADMAP 2026-10-12 adds #1239–#1374 (community PRs of the day)
2026-10-12 17:28:39 +07:00
BFlat fc0f732677 Merge pull request #1376 from storytold/fix/font-fallback-retry-after-rescan
Fonts: a character no font covered is looked for again after a font rescan
2026-10-12 16:48:05 +07:00
BFlat ef0c37349d Merge pull request #1375 from storytold/fix/artboard-flush-roundoff
Artboards: art flush with an artboard moves with it despite a rounding error
2026-10-12 16:47:55 +07:00
Brandon ThomasandClaude Opus 5.5 71174477cc Release prep: 0.10.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 05:44:41 -04:00
bflatastic 9d63926291 PDF import: a PDF saved with its editing data opens on its artboard with its bleed
A PDF-compatible .ai opens on its trim boxes, its editor's artboards,
with the bleed boxes round them as the document's bleed (#1349). A PDF
saved with its editing data (#1345) keeps that data under
AIPDFPrivateData keys, which has_illustrator_data didn't look for, so
such a PDF with a bleed still opened on its crop box: the artboard took
the bleed in and the editing data was refused as being for a different
page. has_illustrator_data now finds either key. has_private_data, which
also decides how a page is clipped, is unchanged.

Test: a_file_with_a_bleed_opens_on_its_artboard_with_the_bleed_round_it
(the .ai case, and the same hand-written file with AIPDFPrivateData).
2026-10-12 16:44:16 +07:00
bflatastic 2cf0452beb Docs: ROADMAP 2026-10-12 adds #1239, #1250, #1252, #1308, #1318, #1335, #1339, #1345–#1359 2026-10-12 16:44:16 +07:00
bflatasticandsanyamsingh001 4251e337c1 Fonts: a character no font covered is looked for again after a font rescan (#992)
A character missing from every installed font was remembered as missing for the whole
session, so installing a font that has it didn't help until a restart. A rescan of the
font folders now forgets those misses, and a search a rescan overtook doesn't record one.

Taken from #992 by sanyamsingh001, without its font file probe.

Co-authored-by: sanyamsingh001 <[email protected]>
2026-10-12 16:43:41 +07:00
bflatasticandLuca Lorenz 8b96db8f0b Artboards: art flush with an artboard moves with it despite a rounding error (#1295)
A background drawn at the artboard's own position could sit 3e-14 pt outside it and
stay behind when the artboard moved, was copied or scaled its art. Art within a
millionth of a point of the artboard's edges now counts as inside it.

The fix and both tests are from #1295 by Luca Lorenz, without its change to art that
only partly overlaps the artboard.

Co-authored-by: Luca Lorenz <[email protected]>
2026-10-12 16:43:41 +07:00
BFlat 8716fc8fbc Merge pull request #1374 from storytold/fix/1358-trace-panel-width
Part of #1358: the Image Trace section no longer widens the Properties panel
2026-10-12 16:42:01 +07:00
532 changed files with 65922 additions and 3814 deletions

No files matched your search

+1
View File
@@ -19,6 +19,7 @@ concurrency:
jobs:
lint:
timeout-minutes: 15
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
+8
View File
@@ -35,6 +35,7 @@ env:
jobs:
version:
timeout-minutes: 10
name: version
runs-on: ubuntu-latest
# No `environment: release`: no secrets here.
@@ -63,6 +64,7 @@ jobs:
echo "### VectorCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
macos:
timeout-minutes: 90
name: macOS universal
needs: version
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
@@ -113,6 +115,7 @@ jobs:
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
windows:
timeout-minutes: 90
name: Windows ${{ matrix.arch }}
needs: version
runs-on: windows-latest
@@ -174,6 +177,7 @@ jobs:
if-no-files-found: error
linux:
timeout-minutes: 90
name: Linux ${{ matrix.arch }}
needs: version
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
@@ -243,6 +247,7 @@ jobs:
if-no-files-found: error
flatpak:
timeout-minutes: 90
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
@@ -335,6 +340,7 @@ jobs:
if-no-files-found: error
web:
timeout-minutes: 90
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
@@ -378,6 +384,7 @@ jobs:
if-no-files-found: error
linux-riscv64:
timeout-minutes: 90
name: Linux riscv64
needs: version
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
@@ -458,6 +465,7 @@ jobs:
if-no-files-found: error
release:
timeout-minutes: 10
name: Draft GitHub Release
# Needs the signed jobs, so a branch dry run (where the environment refuses them) never
# drafts a release.
+24
View File
@@ -333,3 +333,27 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
## Assets embedded by the PDF backend
The shared PDF backend keeps the existing standard-font and CMap features. These files remain in dependency sources; no font files are copied into this repository. Hashes identify the pinned dependency contents.
| Crate / file | Author | Source | Licence | SHA-256 | Use |
| --- | --- | --- | --- | --- | --- |
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://crates.io/crates/hayro-cmap/0.1.0 | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
Generated
+82 -41
View File
@@ -806,6 +806,17 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"hayro-interpret",
"hayro-syntax",
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -1777,8 +1788,7 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -1788,7 +1798,7 @@ dependencies = [
"phf",
"rustc-hash 2.1.3",
"siphasher",
"skrifa 0.42.1",
"skrifa 0.47.0",
"smallvec",
"yoke",
]
@@ -1796,8 +1806,7 @@ dependencies = [
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"fearless_simd",
"hayro-ccitt",
@@ -1821,8 +1830,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -1831,6 +1839,7 @@ dependencies = [
"memchr",
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
]
@@ -2221,8 +2230,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
source = "git+https://github.com/storytold/craft-libs?rev=ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d#ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -3640,9 +3648,9 @@ dependencies = [
[[package]]
name = "ruzstd"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
dependencies = [
"twox-hash",
]
@@ -3995,6 +4003,17 @@ dependencies = [
"serde",
]
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "subsetter"
version = "0.2.6"
@@ -4379,6 +4398,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -4490,7 +4518,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4510,6 +4538,7 @@ dependencies = [
"raw-window-handle",
"rfd",
"serde_json",
"socket2",
"vectorcraft-engine",
"vectorcraft-text",
"vectorcraft-ui-egui",
@@ -4524,7 +4553,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4535,7 +4564,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4547,7 +4576,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4563,7 +4592,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4580,11 +4609,12 @@ dependencies = [
"vectorcraft-testkit",
"vectorcraft-text",
"windows",
"winsafe",
]
[[package]]
name = "vectorcraft-color"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4596,7 +4626,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4613,7 +4643,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4628,7 +4658,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4653,6 +4683,7 @@ dependencies = [
"vectorcraft-pdf",
"vectorcraft-plugins",
"vectorcraft-render",
"vectorcraft-scriptcheck",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
@@ -4663,12 +4694,13 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"ruzstd",
"serde_json",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
@@ -4683,10 +4715,11 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"flate2",
"proptest",
"rustix 1.1.5",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -4697,7 +4730,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4709,7 +4742,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"image",
"log",
@@ -4728,7 +4761,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4742,7 +4775,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4754,12 +4787,11 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"aes",
"craft-pdf",
"flate2",
"hayro-interpret",
"hayro-syntax",
"image",
"jpeg-encoder",
"krilla",
@@ -4779,11 +4811,12 @@ dependencies = [
"vectorcraft-geom",
"vectorcraft-testkit",
"vectorcraft-text",
"weezl",
]
[[package]]
name = "vectorcraft-plugins"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"serde",
"serde_json",
@@ -4797,7 +4830,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"flate2",
"gif",
@@ -4820,9 +4853,16 @@ dependencies = [
"weezl",
]
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.11.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"flate2",
"image",
@@ -4846,7 +4886,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4863,7 +4903,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4883,14 +4923,15 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
]
[[package]]
name = "vectorcraft-tools"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4905,7 +4946,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4913,12 +4954,11 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"vectorcraft-geom",
"vectorcraft-pathops",
]
[[package]]
name = "vectorcraft-ui-egui"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"craft-ui",
"egui",
@@ -4947,11 +4987,12 @@ dependencies = [
"vectorcraft-three-d",
"vectorcraft-tools",
"wat",
"web-time",
]
[[package]]
name = "vectorcraft-web"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"eframe",
"egui",
@@ -6061,7 +6102,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"serde_json",
]
+14 -3
View File
@@ -4,13 +4,14 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
@@ -37,6 +38,7 @@ vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
vectorcraft-affinity = { path = "crates/affinity" }
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -60,13 +62,16 @@ wat = "1.228"
vello_cpu = { version = "0.2", features = ["f32_pipeline"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
usvg = "0.48"
krilla = "0.8"
# Without `simple-text` (its text shaping, the only user of rustybuzz and ttf-parser, which are
# unmaintained, #1730): the PDF writer places glyphs itself. A test of the PDF crate turns it on.
krilla = { version = "0.8", default-features = false, features = ["raster-images"] }
skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.5"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
rustix = { version = "1.1", default-features = false }
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
@@ -107,3 +112,9 @@ opt-level = 1
[profile.release]
lto = "thin"
codegen-units = 1
[patch.crates-io]
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d" }
+20
View File
@@ -72,3 +72,23 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Shared PDF backend
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0
are fetched from https://github.com/storytold/craft-libs at revision
ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d. Their upstream project is
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
crate's upstream licence files. The patch ledger is at
https://github.com/storytold/craft-libs/blob/ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d/vendor/README.craft-reader.md.
Assets already bundled through Hayro's default features retain their licences:
- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
Original code copyright 2014 Foxit Software Inc. BSD-3-Clause; see
licenses/pdf-backend/hayro-interpret/assets/LICENSE_FOXIT.
- CGATS001Compat-v2-micro.icc: CC0-1.0, Clinton Ingram; see
licenses/pdf-backend/hayro-interpret/assets/CGATS_LICENSE.txt.
- Predefined CMaps: Copyright 1990-2023 Adobe. All rights reserved. BSD-3-Clause,
non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt.
Adobe and contributor names do not endorse or promote this product.
Per-file hashes and sources are recorded in ASSETS.md.
+4 -1
View File
@@ -148,6 +148,9 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
## Quick start
```sh
git clone https://github.com/storytold/vectorcraft
cd vectorcraft
cargo run --release -p vectorcraft # desktop app
cargo run --release -p vectorcraft -- examples/dusk-poster.vectorcraft
cargo run --release -p vectorcraft -- --control 7979 # + JSON control channel
@@ -217,7 +220,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, German, Japanese, Korean, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus);
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against Illustrator's public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
+37 -7
View File
@@ -1,12 +1,15 @@
# VectorCraft Roadmap
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
**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-12 · **Change:** minor (G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (PDF.R1: shared PDF reader with windowed content scanning and explicit incomplete-content errors; scores and estimates unchanged; PDF.W1: guarded shared PDF writer; output behavior, scores and estimates unchanged; later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; macOS save sheets, #1855, the Format menu withdrawn; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; headline numbers unchanged; Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; 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
@@ -26,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 29/57, 3D 1/4 (initial Revolve) |
| **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 29/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 |
@@ -35,7 +38,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| Audience | Ready % | Opus 5.5 agent hours to ~95% (one agent) | With 4–6 agents | Work that dominates |
|---|---:|---:|---:|---|
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (60–100 h) and ecosystem |
| **Full Illustrator** (ready for real work; decides the stage) | **~62%** (57–67%) | **~340–560 h** | ~95–160 h | the mainstream work below, plus 3D (50–85 h), raster effects (23–39 h), advanced type (20–28 h), generative AI (30–60 h), localization (55–90 h) and ecosystem |
| **Mainstream illustrator** | **~65%** (60–70%) | **~230–380 h** | ~65–110 h | the interaction-fidelity pass (60–90 h), hardening and QA (30–50 h), stability (20–35 h), performance budgets (15–25 h), `.ai` exchange (10–20 h), the remaining depth of the everyday areas |
| **Essentials user** | **~75%** (70–80%) | **~60–105 h** | ~25–45 h | launch and stability on Windows (20–35 h), in-app help and onboarding (8–15 h), basic QA (10–15 h), Shape Builder regions (4–8 h), UI polish |
@@ -52,7 +55,7 @@ everything is measured from our code against Illustrator's public documentation.
| UI/UX fidelity | 55% | 70–110 | [ui-parity.md](docs/ui-parity.md) |
| File formats | 75% | 15–30 | [file-format-parity.md](docs/file-format-parity.md) |
| Hardware (GPU, pen, touch, displays) | 35% | 25–45 | [hardware-parity.md](docs/hardware-parity.md) |
| Localization (12 key languages) | 63% (measured) | 60–100 | [localization-parity.md](docs/localization-parity.md) |
| Localization (12 key languages) | 71% (measured) | 55–90 | [localization-parity.md](docs/localization-parity.md) |
| Performance | ~60% | 15–25 | [gaps.md › G15](docs/gaps.md#g15-performance-budgets-and-a-gpu-renderer) |
| Stability | ~60% | 30–50 | [gaps.md › G10](docs/gaps.md#g10-launch-and-stability-on-real-machines) |
| Platforms | 80% | 15–25 | [gaps.md › G8](docs/gaps.md#g8-hardening-at-scale) |
@@ -74,7 +77,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
| 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 |
| 3D and Materials | ~8% | 50–85 |
| 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 |
| Symbols, blends, envelopes, Repeat, perspective | 85% | 6–10 |
@@ -107,7 +110,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
| Japanese | `ja` | full | 100% |
| German | `de` | full | 98% |
| Korean | `ko` | full | 100% |
| Vietnamese | `vi` | none | 0% |
| Vietnamese | `vi` | full | 100% |
Also shipped: Traditional Chinese (partial, 80%), Hungarian, Italian, Russian and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
@@ -125,10 +128,32 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
| Date | What landed |
|---|---|
| 2026-10-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
| 2026-10-14 | Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767). Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744). Export for Screens and the Asset Export panel offer TIFF (#1632). SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870). DXF export writes the VPORT view direction once (#1789). Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695). Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717). Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683). A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694). krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875). |
| 2026-10-14 | Image Trace: Shapes finds rounded rectangles and shapes in blurred and scanned art (98 to 100% of the plain-shape inputs, from 57 to 62%; #1845), square-ended lines get square caps (Outline's largest distance 2.94 px to 0.88; #1846), each documented preset has an enhanced version with Auto Grouping and Shapes that measures no worse than the preset (#1847), and blurred or noisy linear gradients trace as one gradient-filled path instead of 3 to 56 (#1848) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). No headline number moves. |
| 2026-10-13 | Synthetic control input has finite click/drag counts and pending-event limits, checked before queue mutation; control-channel bursts yield after 64 requests per frame (#1709). |
| 2026-10-14 | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). A filled Pencil path keeps a stroke of None (#1804, part of #1679). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Select › Same matches type on its characters' fill and stroke (#1746, #1737). Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787). Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757). Slider rails show on Medium Dark (#1808, #1662). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). Control replies are written through a 64 KiB buffer: a 10 MB `document.inspect` reply over loopback on Windows takes 0.25 s instead of 36 s (#1684). MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739). Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). No headline number movLine truncated
| 2026-10-13 | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565). SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596). Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568). The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598). The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601). SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567). PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585). The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584). GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613). Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570). Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590). The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560). Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616). Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571). Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel kLine truncated
| 2026-10-13 | Control sockets close after bounded idle, partial-line and reply-write waits, returning their connection slot (#1726); existing command processing timeout remains separate. |
| 2026-10-13 | Image Trace: neighbouring colours share one fitted edge (gaps between them 22 to 50 px of area down to under 2), a 16 megapixel photo traces in 0.9 GB (was 3.9), stroked centre lines lie in the middle of their ink at its width (0.30 to 0.05 px off, 18 to 3 anchors a stroke), nearly level lines are levelled, more sharp corners are kept; new: Ignore Color, the Auto Color and Outline presets, and the Shapes, Auto Grouping and Gradients options (off by default) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
| 2026-10-13 | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Import: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). Live Corners: a round corner next to a curved side curves smoothly instead of sticking out of the shape, and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533). Guides: Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). |
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
| 2026-10-13 | Effect › 3D and Materials: the light takes the documented Intensity, Rotation, Height, Softness and Ambient Light (to 200 %) and can shine from behind the object, for every 3D type; effects saved with the earlier direction are lit exactly as before. |
| 2026-10-13 | Effect › 3D and Materials: Perspective is the documented 0°–160° angle for every 3D type, Revolve included; effects saved with the earlier 0–100 % strength look exactly as before. |
| 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. |
| 2026-10-12 | Effect › 3D and Materials: Extrude & Bevel (depth, twist, taper, solid or hollow caps, five bevel shapes with repeat, spacing, inside and both sides), Inflate (volume, both sides) and Rotate, with Position presets and the rotation and light gizmos; live, exported as vector faces, expandable (#1407). |
| 2026-10-12 | `convert --editing-data` (`editingData: "always"`) opens a `.ai` from its editing data even where its page, which can be older, draws differently, with a note. |
| 2026-10-12 | `.ai` import reads an object's art style (a fill and a stroke, variable widths too, with an Outer Glow or Drop Shadow) as its own appearance, paints a clipping path's own fill under what it clips and keeps a group's transparency as its PDF part draws them. |
| 2026-10-12 | `.ai` import reads gradients with their matrix (radials and skew too), focal point and stop order, gradient meshes and the artboards' names from the editing data. |
| 2026-10-12 | The Rectangular Grid and Polar Grid tools take their options: a click opens the size, the divider counts with a skew slider each, Use Outside Rectangle As Frame (rectangular) or Create Compound Path From Ellipses (polar) and Fill Grid; the choices stay with the tool, so dragging draws with them, and scripts and agents pass them to `shape.rectangularGrid` and `shape.polarGrid` (#1377, in part). |
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
@@ -146,6 +171,11 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
| 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. |
| 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. |
| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away |
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%) |
| 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md |
+6
View File
@@ -26,6 +26,12 @@ vectorcraft-text = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# `perf` on Windows (src/perf.rs) reads how busy the processors are (GetSystemTimes) and this
# process's working set (GetProcessMemoryInfo) through winsafe's safe wrappers over Win32 (MIT),
# which the desktop app's src/gpu.rs also uses.
[target.'cfg(windows)'.dependencies]
winsafe = { version = "0.0.29", default-features = false, features = ["kernel", "psapi"] }
# DirectWrite's system font collection (the desktop app's src/system_fonts.rs, shared): fonts that
# font services such as Adobe Fonts load in place (#579). MIT OR Apache-2.0.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
+34 -12
View File
@@ -4,10 +4,10 @@
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--replace-lossy]
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000]
//! vectorcraft-cli perf [--paths 50000] [--deep]
//! ```
// Denied, not forbidden: the font listers shared with the desktop app allow it for their
// DirectWrite (Windows) and CoreText (macOS) calls, and nothing else may.
@@ -84,14 +84,17 @@ USAGE:
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--replace-lossy]
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
SVG's data-* attributes; --outline-text writes SVG text as paths. OUT may not be IN when reading
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
precision (1–7, default 3). OUT may not be IN when reading
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
--replace-lossy is given.
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
draws differently from its PDF part (which can be older), as long as it can be read. The
result has the export's warnings, and importWarnings for what reading IN left out.
vectorcraft-cli info FILE
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
@@ -102,9 +105,11 @@ USAGE:
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
(warm), multithreaded and single-threaded.
vectorcraft-cli perf [--paths N]
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
vectorcraft-cli perf [--paths N] [--deep]
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
budget is exceeded, unless the report says the machine is busy. --deep also times work with
no budget yet and prints memory use (the working set on Windows).
";
/// The usage text plus the formats `document.open` reads and `document.export` writes.
@@ -215,7 +220,9 @@ fn commands() -> Result<(), String> {
fn convert(args: &[String]) -> Result<(), String> {
let mut files = vec![];
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy) = (1.0f64, None::<u64>, None::<String>, false, false);
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data) =
(1.0f64, None::<u64>, None::<String>, false, false, false);
let mut decimals = None::<u64>;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
@@ -223,17 +230,32 @@ fn convert(args: &[String]) -> Result<(), String> {
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
"--outline-text" => outline_text = true,
"--decimals" => decimals = Some(it.next().and_then(|v| v.parse::<u64>().ok()).ok_or("--decimals needs a number")?),
"--replace-lossy" => replace_lossy = true,
"--editing-data" => editing_data = true,
f => files.push(f.to_string()),
}
}
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
let mut h = Headless::new();
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
let opened = if editing_data {
let params = json!({"path": input, "editingData": "always"});
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?
} else {
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
};
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
// much as what writing the output did.
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
out.insert("importWarnings".into(), w.clone());
}
outln!("{r}");
Ok(())
}
+78 -5
View File
@@ -1,7 +1,8 @@
//! `vectorcraft-cli perf`: the performance budgets (plan §4) measured on a synthetic document.
//!
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load
//! average above the core count) they are noise, and the report says so.
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (a load
//! average above three quarters of the cores, or on Windows more than three quarters of the
//! cores busy over a second) they are noise, and the report says so.
use std::time::Instant;
@@ -61,6 +62,7 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
}
/// 1-minute load average (macOS / Linux), if available.
#[cfg(not(windows))]
fn load_average() -> Option<f64> {
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success());
let text = match out {
@@ -70,6 +72,27 @@ fn load_average() -> Option<f64> {
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
}
/// The cores busy on average over one second (Windows), if available: the share of the
/// processors' time that `GetSystemTimes` reports as kernel or user time but not as idle time,
/// times `cores`. A load average also counts the threads waiting for a core.
#[cfg(windows)]
fn busy_cores(cores: usize) -> Option<f64> {
let times = || winsafe::GetSystemTimes().ok().map(|(idle, kernel, user)| [idle, kernel, user].map(u64::from));
let before = times()?;
std::thread::sleep(std::time::Duration::from_secs(1));
busy_share(before, times()?).map(|share| share * cores as f64)
}
/// The share of the processors' time between two `[idle, kernel, user]` readings of
/// `GetSystemTimes` that was busy (kernel time includes idle time). It is None when no time
/// passed, a time went back, or the idle time exceeds the kernel and user time.
#[cfg(any(windows, test))]
fn busy_share([idle0, kernel0, user0]: [u64; 3], [idle1, kernel1, user1]: [u64; 3]) -> Option<f64> {
let total = kernel1.checked_sub(kernel0)?.checked_add(user1.checked_sub(user0)?)?;
let busy = total.checked_sub(idle1.checked_sub(idle0)?)?;
(total > 0).then(|| busy as f64 / total as f64)
}
/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
fn edit_drags(n: usize) -> Result<(f64, f64), String> {
@@ -114,12 +137,21 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
}
/// Resident memory of this process in MB (`ps`), if available.
#[cfg(not(windows))]
fn rss_mb() -> Option<f64> {
let pid = std::process::id().to_string();
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
}
/// The working set of this process in MB (`GetProcessMemoryInfo`), if available: its pages that
/// are in physical memory, the figure `ps` reports as resident memory elsewhere.
#[cfg(windows)]
fn rss_mb() -> Option<f64> {
let counters = winsafe::HPROCESS::GetCurrentProcess().GetProcessMemoryInfo().ok()?;
Some(counters.WorkingSetSize as f64 / (1024.0 * 1024.0))
}
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
fn deep(n: usize) -> Result<(), String> {
@@ -243,8 +275,10 @@ fn deep(n: usize) -> Result<(), String> {
for (name, ms) in &rows {
outln!(" {name:<52} {ms:>10.2} ms");
}
// `rss_mb` reads the working set on Windows.
let memory = if cfg!(windows) { "working set" } else { "memory" };
for (what, mb) in &mem {
outln!(" memory {what:<45} {mb:>8.0} MB");
outln!(" {:<52} {mb:>8.0} MB", format!("{memory} {what}"));
}
Ok(())
}
@@ -261,8 +295,12 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
}
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let load = load_average();
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
#[cfg(not(windows))]
let (load, label) = (load_average(), "load average");
#[cfg(windows)]
let (load, label) = (busy_cores(cores), "cores busy");
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, {label} {}", load.map_or("?".into(), |l| format!("{l:.1}")));
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
if noisy {
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
@@ -358,3 +396,38 @@ pub fn run(args: &[String]) -> Result<(), String> {
_ => Err(format!("{over} budget(s) exceeded")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn busy_share_leaves_out_idle_time() {
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
// 10,000,000 of them idle.
assert_eq!(busy_share([7, 100, 50], [10_000_007, 25_000_100, 15_000_050]), Some(0.75));
// There is no share when no time passed, a time went back, or the idle time exceeds the
// kernel and user time.
assert_eq!(busy_share([7, 100, 50], [7, 100, 50]), None);
assert_eq!(busy_share([7, 100, 50], [8, 99, 60]), None);
assert_eq!(busy_share([0, 0, 0], [30, 10, 10]), None);
}
/// On Windows `busy_cores` gives a number between 0 and the cores.
#[cfg(windows)]
#[test]
fn windows_reads_how_many_cores_are_busy() {
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
let busy = busy_cores(cores);
assert!(busy.is_some_and(|b| (0.0..=cores as f64).contains(&b)), "{busy:?} of {cores}");
}
/// On Windows `rss_mb` gives this process's working set: more than 0 MB and less than
/// 10,000 MB.
#[cfg(windows)]
#[test]
fn windows_reads_the_working_set() {
let mb = rss_mb();
assert!(mb.is_some_and(|mb| mb > 0.0 && mb < 10_000.0), "{mb:?}");
}
}
+22
View File
@@ -83,6 +83,12 @@ fn help_lists_readable_formats() {
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
}
#[test]
fn help_lists_perf_deep() {
let out = ok(&["--help"]);
assert!(out.contains("vectorcraft-cli perf [--paths N] [--deep]") && out.contains("--deep also times"), "{out}");
}
/// `info` reports the import warnings: an EPS read only up to an error names it.
#[test]
fn info_reports_why_an_eps_read_partly() {
@@ -93,3 +99,19 @@ fn info_reports_why_an_eps_read_partly() {
assert!(w.contains("`frobnicate`"), "{v}");
assert_eq!(v["kinds"]["Path"], 1, "{v}");
}
/// #1604: convert reports what reading the input left out (here a missing linked image) as
/// importWarnings, next to the export's own warnings.
#[test]
fn convert_reports_import_warnings() {
let svg = tmp("missing-link.svg");
std::fs::write(
&svg,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><image width="40" height="40" href="nowhere-to-be-found.png"/></svg>"#,
)
.unwrap();
let png = tmp("missing-link.png");
let out: Value = serde_json::from_str(ok(&["convert", &svg, &png]).trim()).unwrap();
let w = out["importWarnings"].as_array().unwrap_or_else(|| panic!("{out}"));
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
}
+7 -8
View File
@@ -205,7 +205,8 @@ fn track_paste(pasted: &Rc<RefCell<Pasted>>) {
let Some(data) = e.clipboard_data() else { return };
let Some(files) = data.files().filter(|f| f.length() > 0) else { return };
// Text is egui's, but for the address a browser's Copy Image puts next to the picture.
if data.get_data("text").is_ok_and(|t| !t.is_empty() && !is_address(&t)) {
// Blank text is not a caption. A picture pasted with it is still the picture.
if data.get_data("text").is_ok_and(|t| !t.trim().is_empty() && !is_address(&t)) {
return;
}
e.prevent_default();
@@ -309,7 +310,7 @@ fn notice(lines: &[&str]) {
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened
/// (libraries, presets and plug-ins open wherever they are dropped).
/// (libraries, presets, plug-ins and shortcut sets open wherever they are dropped).
/// When the graphics are lost it hands the app to a new runner on a new canvas.
struct WebShell {
/// `None` once handed on.
@@ -433,11 +434,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op
ctx.request_repaint();
});
})),
download: Some(Box::new(|name: &str, bytes: &[u8]| {
if let Err(e) = download(name, bytes) {
log::error!("download of {name} failed: {e}");
}
})),
// A download that couldn't be started fails the save or export it is for (#1693).
download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))),
inbox: Some(inbox),
recovery_store: Some(Arc::new(BrowserStore { locks })),
// File → Print: the browser's print dialog.
@@ -516,7 +514,8 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url).ok();
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
// The download has started: a timer that can't be set leaves the URL allocated, no more.
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
Ok(())
}
+4
View File
@@ -80,3 +80,7 @@ wayland-protocols = { version = "0.32", features = ["client"] }
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
winresource = "0.1"
[dev-dependencies]
# Bound socket buffers in real loopback transport deadline regressions.
socket2 = { workspace = true }
+97 -2
View File
@@ -1,6 +1,8 @@
//! Parse automation roots before starting any desktop services. A malformed flag must not
//! silently remove a root and leave the control channel unconfined.
//! Parse the control port and automation roots before starting any desktop services. A malformed
//! flag must not silently remove a root and leave the control channel unconfined, or leave the app
//! running without the control channel that was asked for.
use std::ffi::OsString;
use std::path::PathBuf;
pub struct AutomationArgs {
@@ -46,6 +48,50 @@ impl AutomationArgs {
}
}
/// `--control <port>` or `--control=<port>`, given at most once.
#[derive(Default)]
pub struct ControlArg {
pub port: Option<u16>,
}
impl ControlArg {
/// Returns whether this argument is `--control`. The next argument is its port whatever it
/// is, so `--control --automation-read-root` reports the flag rather than reading it as a root.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let value = match arg.strip_prefix("--control") {
Some("") => args.next(),
Some(rest) => match rest.strip_prefix('=') {
Some(value) => Some(value.to_owned()),
None => return Ok(false),
},
None => return Ok(false),
};
if self.port.is_some() {
return Err("--control given twice".into());
}
self.port = Some(port("--control", value.as_deref())?);
Ok(true)
}
}
/// `VECTORCRAFT_CONTROL_PORT`, for when `--control` isn't given: unset or empty is no control
/// channel, anything else must be a port.
pub fn env_control_port(value: Option<OsString>) -> Result<Option<u16>, String> {
match value {
Some(v) if !v.is_empty() => port("VECTORCRAFT_CONTROL_PORT", Some(&v.to_string_lossy())).map(Some),
_ => Ok(None),
}
}
/// A port from 1 to 65535 (the server would listen on 0's random port without saying which).
fn port(name: &str, value: Option<&str>) -> Result<u16, String> {
let need = format!("{name} needs a port number from 1 to 65535");
match value {
None => Err(need),
Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -87,4 +133,53 @@ mod tests {
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
fn control(args: &[&str]) -> Result<Option<u16>, String> {
let mut control = ControlArg::default();
let mut args = args.iter().map(|a| a.to_string());
while let Some(a) = args.next() {
assert!(control.consume(&a, &mut args)?, "{a}");
}
Ok(control.port)
}
#[test]
fn control_needs_a_port_from_1_to_65535() {
let need = "--control needs a port number from 1 to 65535";
assert_eq!(control(&["--control"]), Err(need.into()));
assert_eq!(control(&["--control", "--automation-read-root"]), Err(format!("{need}, not `--automation-read-root`")));
for bad in ["abc", "0", "70000", "-1", ""] {
assert_eq!(control(&["--control", bad]), Err(format!("{need}, not `{bad}`")));
assert_eq!(control(&[&format!("--control={bad}")]), Err(format!("{need}, not `{bad}`")));
}
assert_eq!(control(&["--control", "7979"]), Ok(Some(7979)));
assert_eq!(control(&["--control=65535"]), Ok(Some(65535)));
assert_eq!(control(&["--control", "7979", "--control=7980"]), Err("--control given twice".into()));
}
#[test]
fn other_arguments_are_not_control() {
let mut control = ControlArg::default();
let mut args = ["7979".to_owned()].into_iter();
for other in ["--controls", "--control-port", "drawing.svg", "--in-window-menus"] {
assert!(!control.consume(other, &mut args).unwrap(), "{other}");
}
assert_eq!(args.next().as_deref(), Some("7979"));
assert_eq!(control.port, None);
}
#[test]
fn control_port_variable_is_a_port_or_unset() {
assert_eq!(env_control_port(None), Ok(None));
assert_eq!(env_control_port(Some("".into())), Ok(None));
assert_eq!(env_control_port(Some("7979".into())), Ok(Some(7979)));
let need = "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535";
assert_eq!(env_control_port(Some("abc".into())), Err(format!("{need}, not `abc`")));
assert_eq!(env_control_port(Some("0".into())), Err(format!("{need}, not `0`")));
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
assert_eq!(env_control_port(Some(OsString::from_vec(vec![0x39, 0xff]))), Err(format!("{need}, not `9\u{fffd}`")));
}
}
}
+93 -46
View File
@@ -2,20 +2,21 @@
//! (`Services::system_clipboard`).
//!
//! - Windows: text, SVG, PDF, PNG and an opaque bitmap (for apps that don't read PNG) in one go;
//! Paste reads each of them, a bitmap as PNG, else as the device-independent bitmap that
//! Paste reads each of them and EPS, a bitmap as PNG, else as the device-independent bitmap that
//! screenshots and most apps copy (`CF_DIBV5`, `CF_DIB`; the system makes them of a `CF_BITMAP`).
//! - macOS and Linux: one format at a time, the text (a type-only copy's text or the SVG markup),
//! else the PNG as a bitmap; Paste reads text and bitmaps (on macOS the pasteboard's PNG or TIFF
//! as they are, as screenshots copy them). On Wayland, arboard's data-control
//! else the PNG as a bitmap; Paste reads text and bitmaps, and on macOS the pasteboard's SVG, PDF
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
//! copied), falling back to X11 where the compositor lacks the protocol.
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, a
//! metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
use std::io::{Cursor, Read};
use std::path::PathBuf;
use vectorcraft_engine::cmd::clipboard::{BITMAP, FILE_HEAD, Flavour, PNG, TEXT, file_flavour};
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
use vectorcraft_ui_egui::SystemClipboard;
/// This platform's system clipboard.
@@ -27,6 +28,34 @@ pub fn system_clipboard() -> Box<dyn SystemClipboard> {
cb
}
/// The registered Windows clipboard formats a flavour is written under (and read from, first held
/// first).
#[cfg_attr(not(windows), allow(dead_code))]
fn windows_formats(mime: &str) -> &'static [&'static str] {
match mime {
SVG => &["image/svg+xml"],
PDF => &["Portable Document Format", "application/pdf"],
EPS => &["Encapsulated PostScript", "application/postscript"],
PNG => &["PNG"],
_ => &[],
}
}
/// The macOS pasteboard types Paste reads for `mime` (`image/*`: any bitmap), best first, each with
/// the MIME type of its data. They are the system's uniform type identifiers (the values of
/// `NSPasteboardTypePDF`, `NSPasteboardTypePNG` and `NSPasteboardTypeTIFF` among them) and the
/// older name some apps still give PDF. Text is read through arboard.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn pasteboard_types(mime: &str) -> &'static [(&'static str, &'static str)] {
match mime {
SVG => &[("public.svg-image", SVG)],
PDF => &[("com.adobe.pdf", PDF), ("Apple PDF pasteboard type", PDF)], // brand-ok: the system's type identifier
EPS => &[("com.adobe.encapsulated-postscript", EPS)], // brand-ok: the system's type identifier
BITMAP => &[("public.png", PNG), ("public.tiff", "image/tiff")],
_ => &[],
}
}
/// The text flavour among `flavours`.
fn text_of(flavours: &[Flavour]) -> Option<String> {
flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned())
@@ -127,27 +156,15 @@ mod win {
use std::num::NonZeroU32;
use clipboard_win::{formats, options::NoClear, raw};
use vectorcraft_engine::cmd::clipboard::{PDF, SVG};
use super::*;
/// The registered clipboard formats a flavour is written under (and read from, first held
/// first).
fn names(mime: &str) -> &'static [&'static str] {
match mime {
SVG => &["image/svg+xml"],
PDF => &["Portable Document Format", "application/pdf"],
PNG => &["PNG"],
_ => &[],
}
}
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
/// other or a `CF_BITMAP` is: the system converts between them.
const DIBS: [u32; 2] = [formats::CF_DIBV5, formats::CF_DIB];
fn formats_of(mime: &str) -> impl Iterator<Item = u32> {
names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
windows_formats(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
}
fn err(e: impl std::fmt::Display) -> String {
@@ -350,27 +367,30 @@ mod portable {
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
return Some(f);
}
mimes.iter().find_map(|m| match *m {
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
BITMAP => {
#[cfg(target_os = "macos")]
if let Some(f) = super::mac::bitmap() {
return Some(f);
mimes.iter().find_map(|m| {
#[cfg(target_os = "macos")]
if let Some(f) = super::mac::read(m) {
return Some(f);
}
match *m {
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
BITMAP => {
let img = cb.get_image().ok()?;
let rgba =
image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
png_flavour(&rgba)
}
let img = cb.get_image().ok()?;
let rgba = image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
png_flavour(&rgba)
_ => None,
}
_ => None,
})
}
/// Text, and on macOS a bitmap (by the pasteboard's types). Linux has no cheap way to ask
/// for a bitmap (reading one costs as much as pasting it): the paste keys take it, the
/// menu item waits for text.
/// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no
/// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys
/// take it, the menu item waits for text.
fn has(&mut self, mimes: &[&'static str]) -> bool {
#[cfg(target_os = "macos")]
if mimes.contains(&BITMAP) && super::mac::has_bitmap() {
if super::mac::has(mimes) {
return true;
}
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
@@ -378,7 +398,8 @@ mod portable {
}
}
/// The macOS pasteboard's bitmaps as they are (arboard reads TIFF only, and decodes it).
/// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the
/// pictures TIFF only, decoded).
#[cfg(target_os = "macos")]
mod mac {
use objc2::rc::autoreleasepool;
@@ -387,24 +408,20 @@ mod mac {
use super::*;
/// The bitmap types Paste reads, best first (the values of `NSPasteboardTypePNG` and
/// `NSPasteboardTypeTIFF`), with their MIME types.
const TYPES: [(&str, &str); 2] = [("public.png", PNG), ("public.tiff", "image/tiff")];
/// Does the pasteboard hold a bitmap? Asks for its types only.
pub fn has_bitmap() -> bool {
/// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only.
pub fn has(mimes: &[&'static str]) -> bool {
autoreleasepool(|_| {
let types = TYPES.map(|(t, _)| NSString::from_str(t));
let types: Vec<_> = mimes.iter().flat_map(|m| pasteboard_types(m)).map(|(t, _)| NSString::from_str(t)).collect();
let types: Vec<&NSString> = types.iter().map(|t| &**t).collect();
NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
!types.is_empty() && NSPasteboard::generalPasteboard().availableTypeFromArray(&NSArray::from_slice(&types)).is_some()
})
}
/// The pasteboard's bitmap, PNG before TIFF.
pub fn bitmap() -> Option<Flavour> {
/// The pasteboard's data for `mime`, the first of its types held ([`pasteboard_types`]).
pub fn read(mime: &str) -> Option<Flavour> {
autoreleasepool(|_| {
let pasteboard = NSPasteboard::generalPasteboard();
TYPES.into_iter().find_map(|(t, mime)| {
pasteboard_types(mime).iter().find_map(|&(t, mime)| {
let data = pasteboard.dataForType(&NSString::from_str(t))?.to_vec();
(!data.is_empty()).then_some(Flavour { mime, data })
})
@@ -414,7 +431,7 @@ mod mac {
#[cfg(test)]
mod tests {
use vectorcraft_engine::cmd::clipboard::{PASTE_ORDER, SVG};
use vectorcraft_engine::cmd::clipboard::{EMF, PASTE_ORDER};
use super::*;
@@ -448,6 +465,36 @@ mod tests {
assert!(copied_file(vec![], &PASTE_ORDER).is_none());
}
/// What another app copied as vector art is asked for under the names the platforms give it,
/// and read as the flavour the paste imports (#1499).
#[test]
fn vector_art_is_read_under_each_platforms_names() {
let mac = |m| pasteboard_types(m).iter().map(|t| t.0).collect::<Vec<_>>();
assert_eq!(mac(PDF), ["com.adobe.pdf", "Apple PDF pasteboard type"]);
assert_eq!(mac(EPS), ["com.adobe.encapsulated-postscript"]);
assert_eq!(mac(SVG), ["public.svg-image"]);
assert_eq!(pasteboard_types(BITMAP), [("public.png", PNG), ("public.tiff", "image/tiff")]);
assert!(windows_formats(EPS).contains(&"Encapsulated PostScript") && windows_formats(PDF).contains(&"Portable Document Format"));
for asked in PASTE_ORDER {
// Each type's data is the flavour asked for (a bitmap its own type): what
// `clipboard.import*` then reads. Text and metafiles come another way.
assert!(pasteboard_types(asked).iter().all(|(_, mime)| *mime == asked || asked == BITMAP), "{asked}");
assert_eq!(pasteboard_types(asked).is_empty(), [TEXT, EMF].contains(&asked), "{asked}");
}
// Vector art before text, text before bitmaps.
let at = |m| PASTE_ORDER.iter().position(|x| *x == m).unwrap();
assert!(at(PDF) < at(EPS) && at(EPS) < at(TEXT) && at(TEXT) < at(BITMAP));
}
/// An .eps file copied in a file manager pastes as EPS.
#[test]
fn a_copied_eps_file_pastes() {
let eps = dir("eps").join("equation.eps");
std::fs::write(&eps, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n0 0 10 10 rectfill\n").unwrap();
assert_eq!(copied_file(vec![eps.clone()], &PASTE_ORDER).map(|f| f.mime), Some(EPS));
assert!(copied_file(vec![eps], &[BITMAP, TEXT]).is_none());
}
#[test]
fn a_crlf_left_by_the_uri_list_is_ignored() {
let red = dir("crlf").join("red.png");
+85
View File
@@ -0,0 +1,85 @@
//! Absolute transport budgets, independent of the time spent executing a request.
use std::io::{self, Read, Write};
use std::net::TcpStream;
use std::time::{Duration, Instant};
fn deadline(budget: Duration) -> io::Result<Instant> {
Instant::now().checked_add(budget).ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "control transport deadline out of range"))
}
fn remaining(until: Option<Instant>) -> io::Result<Duration> {
until
.and_then(|d| d.checked_duration_since(Instant::now()))
.filter(|d| !d.is_zero())
.ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "control transport deadline exceeded"))
}
pub(super) struct DeadlineReader {
stream: TcpStream,
idle: Duration,
line: Duration,
until: Option<Instant>,
waiting_for_first_byte: bool,
}
impl DeadlineReader {
pub(super) fn new(stream: TcpStream, idle: Duration, line: Duration) -> Self {
Self { stream, idle, line, until: None, waiting_for_first_byte: false }
}
pub(super) fn begin_line(&mut self, buffered: bool) -> io::Result<()> {
// A partial next request already in BufReader must not get an idle budget too.
self.waiting_for_first_byte = !buffered;
self.until = Some(deadline(if buffered { self.line } else { self.idle })?);
Ok(())
}
}
impl Read for DeadlineReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
self.stream.set_read_timeout(Some(remaining(self.until)?))?;
let n = self.stream.read(buf)?;
if n > 0 && self.waiting_for_first_byte {
self.until = Some(deadline(self.line)?);
self.waiting_for_first_byte = false;
}
Ok(n)
}
}
pub(super) struct DeadlineWriter {
stream: TcpStream,
budget: Duration,
until: Option<Instant>,
}
impl DeadlineWriter {
pub(super) fn new(stream: TcpStream, budget: Duration) -> Self {
Self { stream, budget, until: None }
}
pub(super) fn begin_reply(&mut self) -> io::Result<()> {
self.until = Some(deadline(self.budget)?);
Ok(())
}
}
impl Write for DeadlineWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
// Each partial write gets the remaining budget, never a fresh full timeout.
self.stream.set_write_timeout(Some(remaining(self.until)?))?;
self.stream.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.stream.set_write_timeout(Some(remaining(self.until)?))?;
self.stream.flush()
}
}
+384 -23
View File
@@ -7,7 +7,7 @@
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
//! never reaches the UI thread. See `docs/control-protocol.md`.
use std::io::{BufRead, BufReader, Read, Write};
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -17,11 +17,45 @@ use std::time::Duration;
use serde_json::{Value, json};
use vectorcraft_ui_egui::ControlRequest;
#[path = "control_deadlines.rs"]
mod deadlines;
use deadlines::{DeadlineReader, DeadlineWriter};
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
/// anything longer is refused and the connection closed.
pub const MAX_LINE: usize = 4 * 1024 * 1024;
/// Connections served at once; further ones get an error line and are closed.
pub const MAX_CONNECTIONS: usize = 16;
/// Size in bytes of the buffer that [`write_line`] writes a reply line through.
const REPLY_CHUNK: usize = 64 * 1024;
/// How long a request waits for the app's reply.
const REPLY_WAIT: Duration = Duration::from_secs(60);
/// The reply to a request the app didn't answer in [`REPLY_WAIT`]. One it had not started is
/// withdrawn, so it never runs after its caller was told it failed; one it is running can't be
/// stopped, and the reply says its outcome isn't known.
fn timed_out(ticket: &vectorcraft_ui_egui::ControlTicket) -> Value {
let error = if ticket.give_up() {
"timeout: the app did not start the request in time; it was withdrawn and will not run"
} else {
"timeout: the app is still running the request; it may yet complete"
};
json!({"ok": false, "error": error})
}
#[derive(Clone, Copy)]
struct ConnectionTimeouts {
idle: Duration,
line: Duration,
write: Duration,
}
impl Default for ConnectionTimeouts {
fn default() -> Self {
Self { idle: Duration::from_secs(300), line: Duration::from_secs(30), write: Duration::from_secs(60) }
}
}
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
@@ -39,31 +73,78 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
for stream in listener.incoming().flatten() {
let Some(slot) = acquire_slot(&open) else {
let mut out = DeadlineWriter::new(stream, ConnectionTimeouts::default().write);
// Best effort: the connection is dropped either way, and a peer that can't read the reason loses nothing.
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
if out.begin_reply().is_ok() {
let _ = write_line(&mut out, &json!({"ok": false, "error": "too many control connections"}));
}
continue;
};
if let Err(e) = spawn_connection(stream, tx.clone(), ctx.clone(), slot, ConnectionTimeouts::default()) {
eprintln!("vectorcraft: control worker failed to start: {e}");
}
let tx = tx.clone();
let ctx = ctx.clone();
let open = Arc::clone(&open);
std::thread::spawn(move || {
serve(stream, &tx, &ctx);
open.fetch_sub(1, Ordering::SeqCst);
});
}
}
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
struct ConnectionSlot(Arc<AtomicUsize>);
fn acquire_slot(open: &Arc<AtomicUsize>) -> Option<ConnectionSlot> {
let mut n = open.load(Ordering::SeqCst);
loop {
if n >= MAX_CONNECTIONS {
return None;
}
match open.compare_exchange_weak(n, n + 1, Ordering::SeqCst, Ordering::SeqCst) {
Ok(_) => return Some(ConnectionSlot(Arc::clone(open))),
Err(current) => n = current,
}
}
}
impl Drop for ConnectionSlot {
fn drop(&mut self) {
// Each successful acquisition creates exactly one non-cloneable guard.
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
fn spawn_connection(
stream: TcpStream,
tx: Sender<ControlRequest>,
ctx: egui::Context,
slot: ConnectionSlot,
timeouts: ConnectionTimeouts,
) -> std::io::Result<std::thread::JoinHandle<()>> {
std::thread::Builder::new().name("vectorcraft-control".into()).spawn(move || {
let _slot = slot;
serve_socket(stream, timeouts, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
let ticket = req.ticket();
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(REPLY_WAIT).unwrap_or_else(|_| timed_out(&ticket)))
});
})
}
fn serve_socket(stream: TcpStream, timeouts: ConnectionTimeouts, handle: impl FnMut(String, Value) -> Option<Value>) {
let Ok(read) = stream.try_clone() else { return };
serve_lines(read, stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
tx.send(req).ok()?;
ctx.request_repaint();
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
});
let read = DeadlineReader::new(read, timeouts.idle, timeouts.line);
// Replies reach the writer in [`REPLY_CHUNK`] pieces ([`write_line`]), so checking the remaining
// budget costs one socket option call per piece, not one per JSON token.
let out = DeadlineWriter::new(stream, timeouts.write);
serve_lines_with(
read,
out,
handle,
|reader| {
let buffered = !reader.buffer().is_empty();
reader.get_mut().begin_line(buffered)
},
DeadlineWriter::begin_reply,
);
}
/// One parsed line.
@@ -109,7 +190,11 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
// The line's deadline ran out (Windows reports it as TimedOut, Unix as WouldBlock).
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
_ => e.to_string(),
})?;
if n == 0 {
return Ok(false);
}
@@ -127,15 +212,39 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
Ok(true)
}
/// Write `v` and a newline to `out` through a buffer of [`REPLY_CHUNK`] bytes, then flush `out`.
fn write_line(out: &mut impl Write, v: &Value) -> std::io::Result<()> {
let mut out = BufWriter::with_capacity(REPLY_CHUNK, out);
writeln!(out, "{v}")?;
out.flush()
}
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
/// returns the reply object (the request's `id` is added here).
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
#[cfg(test)]
fn serve_lines<R: Read, W: Write>(read: R, out: W, handle: impl FnMut(String, Value) -> Option<Value>) {
serve_lines_with(read, out, handle, |_| Ok(()), |_| Ok(()));
}
fn serve_lines_with<R: Read, W: Write>(
read: R,
mut out: W,
mut handle: impl FnMut(String, Value) -> Option<Value>,
mut begin_line: impl FnMut(&mut BufReader<R>) -> std::io::Result<()>,
mut begin_reply: impl FnMut(&mut W) -> std::io::Result<()>,
) {
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
if begin_line(&mut reader).is_err() {
return;
}
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => return,
// Nothing of a line arrived (the idle wait ran out, or the peer is gone): close without
// a word. A line nobody asked for would be read as the reply to the client's next request.
Err(_) if buf.is_empty() => return,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
@@ -145,7 +254,7 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
if begin_reply(&mut out).and_then(|()| write_line(&mut out, &r)).is_err() {
return;
}
continue;
@@ -153,7 +262,7 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
},
};
// Best effort: the connection closes either way, and a peer that can't read the reason loses nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
let _ = begin_reply(&mut out).and_then(|()| write_line(&mut out, &json!({"ok": false, "error": reject})));
return;
}
}
@@ -162,6 +271,164 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
mod tests {
use super::*;
fn short_timeouts() -> ConnectionTimeouts {
ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) }
}
fn socket_fixture(
timeouts: ConnectionTimeouts,
handle: impl FnMut(String, Value) -> Option<Value> + Send + 'static,
) -> (TcpStream, Receiver<()>, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (finished, done) = channel();
let worker = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap();
serve_socket(stream, timeouts, handle);
finished.send(()).unwrap();
});
let client = TcpStream::connect(addr).unwrap();
socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap();
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
(client, done, worker)
}
fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) {
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
// Always unblock the old implementation too, so a failed regression leaves no worker behind.
let _ = client.shutdown(std::net::Shutdown::Both);
drop(client);
worker.join().unwrap();
assert!(stopped, "the server retained a stalled connection beyond its transport deadline");
}
#[test]
fn idle_control_socket_releases_its_worker() {
let (client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected"));
expect_socket_deadline(client, done, worker);
}
/// An idle connection is closed without a line: a client that sends its next request late
/// must not find an error waiting where its reply should be.
#[test]
fn idle_expiry_closes_without_writing_a_line() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected"));
let mut got = Vec::new();
client.read_to_end(&mut got).unwrap();
assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection");
expect_socket_deadline(client, done, worker);
}
/// A line that stalls is refused in words a client's author can act on, not the system's.
#[test]
fn stalled_control_line_is_refused_with_the_reason() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute"));
client.write_all(b"{\"method\":").unwrap();
let mut line = String::new();
BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap();
assert_eq!(
serde_json::from_str::<Value>(&line).unwrap(),
json!({"ok": false, "error": "request line not completed in time; closing the connection"})
);
expect_socket_deadline(client, done, worker);
}
#[test]
fn partial_control_line_releases_its_worker() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute"));
client.write_all(b"{\"method\":").unwrap();
expect_socket_deadline(client, done, worker);
}
#[test]
fn trickled_control_line_has_an_absolute_deadline() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("incomplete requests must not execute"));
client.write_all(b"{").unwrap();
let mut writer = client.try_clone().unwrap();
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
let writing = Arc::clone(&stop);
let trickle = std::thread::spawn(move || {
while !writing.load(Ordering::SeqCst) {
if writer.write_all(b" ").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
});
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
stop.store(true, Ordering::SeqCst);
// Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well.
let _ = client.shutdown(std::net::Shutdown::Both);
trickle.join().unwrap();
worker.join().unwrap();
assert!(stopped, "trickled bytes restarted the line's deadline");
}
#[test]
fn a_nonreading_control_peer_cannot_retain_the_writer() {
let payload = "x".repeat(2 * 1024 * 1024);
let (mut client, done, worker) = socket_fixture(short_timeouts(), move |_, _| Some(json!({"ok": true, "result": payload})));
client.write_all(b"{\"method\":\"large\"}\n").unwrap();
expect_socket_deadline(client, done, worker);
}
#[test]
fn persistent_control_requests_get_fresh_transport_deadlines() {
let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() };
let (mut client, done, worker) = socket_fixture(timeouts, |_, _| {
// Application processing is not transport time; begin the reply budget afterwards.
std::thread::sleep(Duration::from_millis(200));
Some(json!({"ok": true}))
});
let mut reader = BufReader::new(client.try_clone().unwrap());
for id in [1, 2] {
writeln!(client, "{}", json!({"id": id, "method": "inspect"})).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}));
}
client.shutdown(std::net::Shutdown::Write).unwrap();
done.recv_timeout(Duration::from_secs(3)).unwrap();
worker.join().unwrap();
}
#[test]
fn buffered_partial_control_line_also_expires() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| Some(json!({"ok": true})));
client.write_all(b"{\"id\":1,\"method\":\"inspect\"}\n{").unwrap();
let mut line = String::new();
BufReader::new(client.try_clone().unwrap()).read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["id"], 1);
expect_socket_deadline(client, done, worker);
}
#[test]
fn timed_out_worker_returns_its_connection_slot() {
let open = Arc::new(AtomicUsize::new(0));
let held: Vec<_> = (0..MAX_CONNECTIONS - 1).map(|_| acquire_slot(&open).unwrap()).collect();
let slot = acquire_slot(&open).unwrap();
assert!(acquire_slot(&open).is_none());
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (stream, _) = listener.accept().unwrap();
let (tx, _rx) = channel();
let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap();
let start = std::time::Instant::now();
while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) {
std::thread::sleep(Duration::from_millis(10));
}
let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS;
// Clean up an old implementation that never returned its slot as well.
let _ = client.shutdown(std::net::Shutdown::Both);
worker.join().unwrap();
assert!(released, "the deadline did not free capacity for the next client");
let replacement = acquire_slot(&open).expect("a new client can reserve the freed slot");
drop(replacement);
drop(held);
assert_eq!(open.load(Ordering::SeqCst), 0);
}
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
let mut out = Vec::new();
let mut calls = Vec::new();
@@ -173,6 +440,50 @@ mod tests {
(replies, calls)
}
/// Counts the `write` calls made on it and keeps the bytes they write.
#[derive(Default)]
struct Writes {
calls: usize,
bytes: Vec<u8>,
}
impl Write for Writes {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.calls += 1;
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// A reply shaped like `document.inspect`'s for a layer of `n` squares in rows of 200.
fn inspect_reply(n: u64) -> Value {
let paths: Vec<Value> = (0..n)
.map(|k| {
let (x, y) = ((k % 200) as f64 * 20.0, (k / 200) as f64 * 20.0);
json!({"anchors": 4, "bounds": {"height": 10.0, "width": 10.0, "x": x, "y": y}, "closed": true, "fill": "#f4a261",
"id": k + 2, "kind": "Path", "locked": false, "name": "<Path>", "stroke": "None", "strokeWidth": 0.0, "visible": true})
})
.collect();
json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}})
}
/// #1725: a request its caller gave up before the app took it never runs; one the app took
/// first can't be withdrawn, and the timeout says so.
#[test]
fn a_timed_out_request_is_withdrawn_unless_it_started() {
let (waiting, _rx) = ControlRequest::new("engine.execute", json!({}));
assert!(timed_out(&waiting.ticket())["error"].as_str().unwrap().contains("will not run"));
assert!(!waiting.claim(), "the app skips a withdrawn request");
let (running, _rx) = ControlRequest::new("engine.execute", json!({}));
assert!(running.claim());
assert!(timed_out(&running.ticket())["error"].as_str().unwrap().contains("still running"));
assert!(!running.claim(), "a request is taken once");
}
#[test]
fn requests_are_answered_in_order_with_ids() {
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
@@ -183,6 +494,25 @@ mod tests {
assert_eq!(r[2]["id"], Value::Null);
}
#[test]
fn replies_are_written_in_few_large_writes() {
for reply in [json!({"ok": true, "result": null}), inspect_reply(2_000)] {
let mut out = Writes::default();
let mut once = Some(reply.clone());
serve_lines(&b"{\"id\":1,\"method\":\"document.inspect\"}\n"[..], &mut out, |_, _| once.take());
let mut want = reply;
want["id"] = json!(1);
let text = String::from_utf8(out.bytes).unwrap();
// The writer receives the bytes that `writeln!(out, "{want}")` writes, in at most one write per 4 KiB.
assert_eq!(text, format!("{want}\n"));
assert!(out.calls <= text.len().div_ceil(4096), "{} writes for {} bytes", out.calls, text.len());
}
// A refusal is one write as well.
let mut out = Writes::default();
serve_lines(&b"hello\n"[..], &mut out, |_, _| None);
assert_eq!(out.calls, 1);
}
#[test]
fn http_request_closes_before_the_body_runs() {
// A browser's cross-origin `fetch` POST: the request line is rejected and the
@@ -263,6 +593,37 @@ mod tests {
assert!(lines[1].contains("closing the connection"));
}
#[test]
fn ten_inspect_replies_for_10000_objects_arrive_within_20_s() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let reply = inspect_reply(10_000);
let mut want = reply.clone();
want["id"] = json!(1);
let want = format!("{want}\n");
std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(reply.clone()));
});
// The client sends each request line and reads its reply line through a `BufReader`, as `vectorcraft_mcp::Remote` does.
let (tx, rx) = channel();
std::thread::spawn(move || {
let mut c = TcpStream::connect(addr).unwrap();
let mut read = BufReader::new(c.try_clone().unwrap());
let mut lines = vec![];
for _ in 0..10 {
c.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
let mut line = String::new();
read.read_line(&mut line).unwrap();
lines.push(line);
}
tx.send(lines).ok();
});
let lines = rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("ten replies did not arrive within 20 s"));
assert!(lines.iter().all(|l| *l == want), "a reply differs from the bytes `writeln!` writes");
}
#[test]
fn extra_connections_are_refused() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+15
View File
@@ -113,6 +113,13 @@ pub fn backends(default: Backends, env_set: bool) -> Backends {
if cfg!(windows) && !env_set { Backends::DX12 | Backends::GL } else { default }
}
/// The largest texture side to ask the device for, which bounds the window's surface: the
/// adapter's own limit when it is above eframe's `default` (8192 px), so a window larger than
/// that keeps its surface (#1459). Never below `default`, which is what was asked for before.
pub fn surface_limit(default: u32, adapter: u32) -> u32 {
default.max(adapter)
}
/// One adapter, as [`adapter_order`] sees it.
#[derive(Clone, Debug)]
pub struct Candidate {
@@ -547,6 +554,14 @@ mod tests {
Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true }
}
/// #1459: a 9048 px wide window (two display scales' ratio past 8192) fits an adapter that
/// takes 16384 px textures; an adapter with less than eframe asks for changes nothing.
#[test]
fn the_surface_may_be_as_large_as_the_adapter_allows() {
assert_eq!(surface_limit(8192, 16384), 16384);
assert_eq!(surface_limit(8192, 4096), 8192);
}
fn names(c: &[Candidate], order: &[usize]) -> Vec<String> {
order.iter().map(|&i| c[i].name.clone()).collect()
}
-137
View File
@@ -1,137 +0,0 @@
//! Format menu on macOS save sheets (#866). rfd's Cocoa backend combines the extensions of
//! every filter but doesn't show their names. The accessory uses the same filters and updates
//! the filename, whose extension already determines the format in the save command.
//!
//! AppKit owns the accessory until the sheet closes; its target is the accessory itself. The
//! panel reference is weak, so closing the sheet releases both without a retain cycle.
//! Native view handles and Objective-C subclassing require the scoped unsafe allowance.
#![allow(unsafe_code)]
use std::path::Path;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
struct Ivars {
panel: Weak<NSSavePanel>,
filters: Vec<Filter>,
}
define_class!(
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
#[unsafe(super = NSView)]
#[thread_kind = MainThreadOnly]
#[ivars = Ivars]
#[name = "VectorcraftSaveFormatAccessory"]
struct FormatAccessory;
// SAFETY: NSObjectProtocol has no additional requirements.
unsafe impl NSObjectProtocol for FormatAccessory {}
impl FormatAccessory {
// SAFETY: AppKit's target/action sends the popup as the single object argument.
#[unsafe(method(chooseFormat:))]
fn choose_format(&self, sender: &NSPopUpButton) {
let Some(panel) = self.ivars().panel.load() else { return };
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
let Some((_, extensions)) = self.ivars().filters.get(index) else { return };
let Some(extension) = extensions.first() else { return };
let name = panel.nameFieldStringValue().to_string();
let new = filename(&name, extension, &self.ivars().filters);
panel.setNameFieldStringValue(&NSString::from_str(&new));
}
}
);
/// The suggested file's type, or the request's default (the first filter).
fn initial_format(name: &str, filters: &[Filter]) -> usize {
let extension = Path::new(name).extension().and_then(|e| e.to_str());
filters.iter().position(|(_, extensions)| extension.is_some_and(|e| extensions.iter().any(|x| e.eq_ignore_ascii_case(x)))).unwrap_or(0)
}
/// Replace a known format suffix; keep other dots (e.g. a version number) as part of the name.
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
if name.is_empty() {
return name.to_owned();
}
let path = Path::new(name);
if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| filters.iter().any(|(_, extensions)| extensions.iter().any(|x| e.eq_ignore_ascii_case(x))))
{
path.with_extension(extension).to_string_lossy().into_owned()
} else {
format!("{name}.{extension}")
}
}
/// Add a Format menu to the save sheet rfd just began on `window`. Open panels and requests
/// without several named file types are left to rfd.
pub fn attach(window: &winit::window::Window, pick: &FilePick) {
let Some(mtm) = MainThreadMarker::new() else { return };
let filters: Vec<_> = pick.filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
if filters.len() < 2 {
return;
}
let Ok(handle) = window.window_handle() else { return };
let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return };
// SAFETY: winit keeps its content view alive while this window reference is in use.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let Some(sheet) = view.window().and_then(|w| w.attachedSheet()) else { return };
let Some(panel) = sheet.downcast_ref::<NSSavePanel>() else { return };
let selected = initial_format(&pick.name, &filters);
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: NSRect::new(NSPoint::ZERO, NSSize::new(360.0, 38.0))] };
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
label.setFrame(NSRect::new(NSPoint::new(0.0, 10.0), NSSize::new(80.0, 20.0)));
let popup =
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(82.0, 6.0), NSSize::new(278.0, 28.0)), false);
for (name, _) in &accessory.ivars().filters {
popup.addItemWithTitle(&NSString::from_str(i18n::t(name)));
}
// The filter list comes from the built-in format registry, small enough for NSInteger.
if let Ok(index) = isize::try_from(selected) {
popup.selectItemAtIndex(index);
}
// SAFETY: the accessory owns the popup and AppKit keeps the accessory while the sheet is
// open. The selector above has exactly the target/action signature expected by NSControl.
unsafe {
popup.setTarget(Some(&accessory));
popup.setAction(Some(sel!(chooseFormat:)));
accessory.addSubview(&label);
accessory.addSubview(&popup);
}
panel.setExtensionHidden(false);
panel.setAccessoryView(Some(&accessory));
}
#[cfg(test)]
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft", "drawcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
#[test]
fn current_suffix_selects_the_format_and_an_unknown_one_uses_the_default() {
for (name, index) in [("Untitled-1", 0), ("旧形式.drawcraft", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0)] {
assert_eq!(initial_format(name, FILTERS), index);
}
}
#[test]
fn changing_format_keeps_the_name_and_avoids_duplicate_extensions() {
for (name, expected) in
[("図面 1.vectorcraft", "図面 1.svg"), ("a.PDF", "a.svg"), ("art", "art.svg"), ("art.v2", "art.v2.svg"), ("art.svg", "art.svg"), ("", "")]
{
assert_eq!(filename(name, "svg", FILTERS), expected);
}
}
}
+47 -9
View File
@@ -6,7 +6,8 @@
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
//!
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`; a port from 1 to 65535, or the app doesn't
//! start) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `vectorcraft_ui_egui::control` for the methods. `--automation-read-root` and
//! `--automation-write-root` (or `VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`) confine the
@@ -34,8 +35,6 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
@@ -44,6 +43,7 @@ mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
mod system_fonts;
mod window;
mod window_frame;
mod xwayland;
use vectorcraft_engine::Session;
@@ -61,6 +61,8 @@ struct App {
graphics_lost: bool,
/// Frames the UI has run (see [`end_before_teardown`]).
frames: u64,
/// Keeps the custom title bar's window borderless (Windows, #1758).
window_frame: Option<window_frame::WindowFrameFn>,
}
impl eframe::App for App {
@@ -71,6 +73,11 @@ impl eframe::App for App {
file_access::unconfined(|| self.logic_frame(ctx, frame));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
// winit gives the window its caption back when it changes the window's flags (maximize,
// restore, …): take it off again before the frame is laid out (#1758).
if let Some(window_frame) = self.window_frame.as_mut() {
window_frame();
}
#[cfg(target_os = "macos")]
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
@@ -86,6 +93,11 @@ impl eframe::App for App {
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
// Preferences are written when confirmed, not only at exit: a session ended any other way
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
if self.app.take_prefs_changed() {
file_access::unconfined(|| save_prefs(&self.app));
}
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
@@ -471,9 +483,10 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(pick) => {
PickRequest::Save(_) => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
let picked = dialog.save_file();
mac_save_format::attach(window, &pick);
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::Folder => {
@@ -488,6 +501,8 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
/// The host's services; `ui` is woken when a file dialog answers.
fn services(parent: Parent, ui: egui::Context) -> Services {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
Services {
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
@@ -497,6 +512,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
// dialogs run the window's events while they are open.
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
@@ -605,7 +621,7 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
fn main() -> std::process::ExitCode {
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut control = automation_args::ControlArg::default();
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
let mut roots_args =
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
@@ -613,7 +629,7 @@ fn main() -> std::process::ExitCode {
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match roots_args.consume(&a, &mut args) {
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
Ok(true) => continue,
Ok(false) => {}
Err(e) => {
@@ -622,7 +638,6 @@ fn main() -> std::process::ExitCode {
}
}
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--in-window-menus" => in_window_menus = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
@@ -632,6 +647,17 @@ fn main() -> std::process::ExitCode {
}
}
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
// After the arguments: `--control` and `--version` win over a bad environment value.
let control_port = match control.port {
Some(port) => Some(port),
None => match automation_args::env_control_port(std::env::var_os("VECTORCRAFT_CONTROL_PORT")) {
Ok(port) => port,
Err(e) => {
eprintln!("vectorcraft: {e}");
return std::process::ExitCode::FAILURE;
}
},
};
// Before desktop services, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
@@ -741,6 +767,16 @@ fn main() -> std::process::ExitCode {
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
create.instance_descriptor.backend_options.dx12.shader_compiler =
eframe::wgpu::Dx12Compiler::from_env().unwrap_or(eframe::wgpu::Dx12Compiler::Fxc);
// The window's surface may be as large as the adapter allows, not eframe's fixed
// 8192 px: a window stretched over several monitors, or grown by a display scale
// change, otherwise fails to configure its surface and the app closes (#1459).
let device = create.device_descriptor.clone();
create.device_descriptor = std::sync::Arc::new(move |adapter: &eframe::wgpu::Adapter| {
let mut descriptor = device(adapter);
let limit = &mut descriptor.required_limits.max_texture_dimension_2d;
*limit = gpu::surface_limit(*limit, adapter.limits().max_texture_dimension_2d);
descriptor
});
}
options
};
@@ -834,7 +870,9 @@ fn main() -> std::process::ExitCode {
// The first frame is due from now (`gpu::watch_first_frame`).
#[cfg(feature = "wgpu")]
created.ready();
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0 }))
// The content and the pointer stay aligned on Windows (#1758).
let window_frame = window_frame::service(cc, custom_titlebar);
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0, window_frame }))
}),
)
});
+75
View File
@@ -0,0 +1,75 @@
//! Keeps the custom title bar's undecorated window truly borderless on Windows (#1758).
//!
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems the
//! desktop compositor still reserves that frame for the window surface: the content is drawn
//! offset by the caption and border while the pointer keeps the un-offset client coordinates, so
//! it no longer points at what is drawn under it (rust-windowing/winit#2419; PhotoCraft fixed the
//! same in storytold/photocraft#2401). [`service`] strips the two styles and has Windows
//! recalculate the frame; winit puts them back whenever it changes the window flags (maximize,
//! restore, …), so the returned guard runs every frame.
/// Run once per frame; a no-op once the window is borderless.
pub type WindowFrameFn = Box<dyn FnMut()>;
pub use platform::service;
#[cfg(all(windows, feature = "wgpu"))]
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
/// A guard that keeps the window borderless each frame, or `None` with the system title bar
/// (the window is decorated) or when its handle can't be found.
pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option<super::WindowFrameFn> {
if !custom_titlebar {
return None;
}
let target = match cc.window_handle().ok()?.as_raw() {
RawWindowHandle::Win32(h) => h.hwnd.get(),
_ => return None,
};
let hwnd = find_window(target)?;
strip(&hwnd);
Some(Box::new(move || strip(&hwnd)))
}
/// The top-level window with the given raw handle: `EnumWindows` hands out real `HWND`s, so
/// this needs no `unsafe` to wrap a raw one.
fn find_window(target: isize) -> Option<HWND> {
let mut found = None;
let _ = winsafe::EnumWindows(|hwnd| {
if hwnd.ptr() as isize == target {
found = Some(hwnd);
false
} else {
true
}
});
found
}
/// Removes `WS_CAPTION` (which includes `WS_BORDER`) and has Windows recalculate the frame;
/// the sizing border stays, so edge resizing and snapping keep working.
fn strip(hwnd: &HWND) {
let style = hwnd.style();
let without = style & !(co::WS::CAPTION | co::WS::BORDER);
if without.raw() == style.raw() {
return;
}
hwnd.set_style(without);
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE::new(),
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
}
#[cfg(not(all(windows, feature = "wgpu")))]
mod platform {
pub fn service(_cc: &eframe::CreationContext<'_>, _custom_titlebar: bool) -> Option<super::WindowFrameFn> {
None
}
}
+26
View File
@@ -9,12 +9,17 @@ const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
/// Invalid roots must exit before desktop initialization on every platform, including a CI
/// machine with no display. A regression must fail rather than hang with a window open.
fn rejects(args: &[&str], message: &str) {
rejects_with_env(args, &[], message);
}
fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
let mut child = Command::new(BIN)
.args(args)
.env_remove("VECTORCRAFT_CONTROL_PORT")
.env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT")
.env_remove("VECTORCRAFT_AUTOMATION_WRITE_ROOT")
.env("VECTORCRAFT_NO_PREFS", "1")
.envs(env.iter().copied())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -68,3 +73,24 @@ fn explicit_dash_folder_does_not_consume_version() {
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
assert!(out.stderr.is_empty());
}
/// `--control` without a port no longer starts the app without a control server, and the
/// argument after it isn't reinterpreted (#1373).
#[test]
fn control_without_a_valid_port_fails_before_startup() {
let need = "--control needs a port number from 1 to 65535";
rejects(&["--control", "--automation-read-root", ".", "--automation-write-root", "."], &format!("{need}, not `--automation-read-root`"));
rejects(&["--control"], need);
rejects(&["--control", "abc"], &format!("{need}, not `abc`"));
rejects(&["--control=0"], &format!("{need}, not `0`"));
rejects(&["--control="], &format!("{need}, not ``"));
rejects(&["--control", "7979", "--control", "7980"], "--control given twice");
rejects_with_env(&[], &[("VECTORCRAFT_CONTROL_PORT", "abc")], "VECTORCRAFT_CONTROL_PORT needs a port number from 1 to 65535, not `abc`");
}
#[test]
fn version_wins_over_a_bad_control_port_variable() {
let out = Command::new(BIN).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
}
+12
View File
@@ -86,3 +86,15 @@ sync_github_name = false
real_name = "Aaron Guo"
display_name = "Aaron Guo"
sync_github_name = false
[people.Nael-Nathanael]
sync_github_name = true
[people.switch87]
real_name = "Gert Pellin"
sync_github_name = false
[people.lewandowskit]
real_name = "Tomasz Lewandowski"
display_name = "Tomasz Lewandowski"
sync_github_name = false
+1 -1
View File
@@ -17,7 +17,7 @@ synth = []
crc32fast = { workspace = true }
flate2 = { workspace = true }
image = { workspace = true }
ruzstd = "0.9"
ruzstd = "0.9.1"
[dev-dependencies]
proptest = { workspace = true }
+15 -2
View File
@@ -296,12 +296,23 @@ const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
const MAX_LAYER_PIXELS: u64 = 512 << 20;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
read_within(bytes, limits, MAX_LAYER_PIXELS)
}
/// [`read`], its pixel layers holding at most `pixels` pixels together.
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive).document()
let mut reader = Reader::new(&s, &mut archive);
reader.pixels_left = pixels;
reader.document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
@@ -311,11 +322,13 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
pub(crate) pixels_left: u64,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS }
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
+43 -1
View File
@@ -105,10 +105,17 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
if u64::from(w) * u64::from(h) > MAX_PIXELS {
let pixels = u64::from(w) * u64::from(h);
if pixels > MAX_PIXELS {
r.warn("pixel layers larger than 64 megapixels");
return Ok(None);
}
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
let Some(left) = r.pixels_left.checked_sub(pixels) else {
r.warn("pixel layers beyond the pixels one document may hold");
return Ok(None);
};
r.pixels_left = left;
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
@@ -404,6 +411,41 @@ mod tests {
}
}
/// Pixel layers of one flat colour: nothing in the archive but `doc.dat` (state 2 is a tile
/// of 0xFF). Written by hand for the reader, like [`fixture`].
fn flat_layers(count: u32, side: i32) -> Vec<u8> {
let layer = |i: u32| {
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"BmpW"), F::I32(side)), (tag(b"BmpH"), F::I32(side))];
for c in 1..=4 {
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![2])));
}
F::Def(10 + i, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap))])
};
let spread = F::Def(1, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared((0..count).map(layer).collect()))]);
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
synth::container(&[("doc.dat", &doc, Method::Zlib)], None)
}
/// #1704: the pixels of all pixel layers together are bounded, also when the archive holds
/// no data for them: the layers past the allowance are left out with a note.
#[test]
fn pixel_layers_share_one_allowance_for_the_whole_document() {
let file = flat_layers(5, 16);
let layers = |pixels: u64| {
let doc = crate::model::read_within(&file, crate::Limits::default(), pixels).unwrap();
let n = doc.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count();
(n, doc.warnings)
};
assert_eq!(layers(5 * 256), (5, vec![]));
let (kept, warnings) = layers(3 * 256 + 255);
assert_eq!(kept, 3);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("pixel layers beyond the pixels one document may hold"), "{warnings:?}");
assert_eq!(crate::read(&file, crate::Limits::default()).unwrap().spreads[0].nodes.len(), 5);
}
#[test]
fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() {
let bytes = png();
+89 -5
View File
@@ -16,6 +16,7 @@ mod defaults;
pub mod track;
mod warp;
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -557,10 +558,11 @@ fn tolerance(scale: f64) -> f64 {
(0.02 * scale).clamp(0.01, 0.25)
}
/// Filled art for one brushed stroke along `bp`. Pieces are in document space with id 0; their
/// appearances carry no brushes (so they never recurse). Stroke-level opacity/blend are the
/// caller's job (`StrokeLayer::opacity`).
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
/// Filled art for one brushed stroke along `bp`, in `doc`. Pieces are in document space with id
/// 0, and never draw a brushed stroke (so they never recurse): their own strokes carry no brush,
/// and what they draw by name that would draw one is left out ([`leave_out_brushed`]).
/// Stroke-level opacity/blend are the caller's job (`StrokeLayer::opacity`).
pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
@@ -573,9 +575,91 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<N
for n in &mut out {
colorize(n, colorization, &stroke.paint);
}
leave_out_brushed(doc, &mut out, &mut Known::new());
out
}
/// A symbol (`false`) or pattern (`true`) of a document, by name: what art draws by name.
type Named<'a> = (bool, &'a str);
/// What [`draws_brushed`] found of each symbol and pattern so far.
type Known = HashMap<(bool, String), bool>;
/// The symbol an instance draws and the patterns that paint `n` (not its children).
fn named(n: &Node) -> impl Iterator<Item = Named<'_>> {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
_ => None,
};
let patterns = vectorcraft_doc::swatches::node_paints(n).filter_map(|p| match p {
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
_ => None,
});
symbol.into_iter().chain(patterns)
}
/// Does drawing the symbol or pattern `def` of `doc` draw a brushed stroke: in its art, or in
/// the art of the symbols and patterns that uses (and so on)? `known`: the answers so far.
fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool {
let key = (def.0, def.1.to_string());
if let Some(&answer) = known.get(&key) {
return answer;
}
let art = |(pattern, name): Named<'_>| -> Vec<&Node> {
match pattern {
false => doc.symbols.iter().filter(|s| s.name == name).take(1).map(|s| &*s.art).collect(),
true => doc.pattern(name).map(|p| p.art.iter().map(|n| &**n).collect()).unwrap_or_default(),
}
};
// Without recursion, each one looked at once: they may use one another without end.
let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def));
let mut answer = false;
while let Some(n) = stack.pop() {
if has_brush(n) {
answer = true;
break;
}
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| &**c));
for other in named(n) {
if seen.insert((other.0, other.1.to_string())) {
stack.extend(art(other));
}
}
}
known.insert(key, answer);
answer
}
/// Leave out of brush pieces `nodes` what they draw by name that draws a brushed stroke
/// ([`draws_brushed`]): such an instance goes, such a pattern paint becomes no paint. A symbol or
/// pattern holding art stroked with the brush itself would draw the brush again, without end.
fn leave_out_brushed(doc: &Document, nodes: &mut Vec<Node>, known: &mut Known) {
fn walk(doc: &Document, n: &mut Node, known: &mut Known) {
for paint in vectorcraft_doc::swatches::node_paints_mut(n) {
if let Paint::Pattern { pattern, .. } = paint
&& draws_brushed(doc, (true, pattern.as_str()), known)
{
*paint = Paint::None;
}
}
if let Some(children) = n.children_mut() {
children.retain(|c| !matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
for c in children.iter_mut() {
walk(doc, Arc::make_mut(c), known);
}
}
if n.extras().mask.is_some()
&& let Some(mask) = n.extras_mut().mask.as_mut()
{
walk(doc, Arc::make_mut(&mut mask.art), known);
}
}
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
for n in nodes.iter_mut() {
walk(doc, n, known);
}
}
/// Pieces for every brushed stroke of `n` (a path or compound path), in appearance order, each
/// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed.
pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
@@ -591,7 +675,7 @@ pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
continue;
}
any = true;
for mut p in stroke_pieces(b, &bp, st) {
for mut p in stroke_pieces(doc, b, &bp, st) {
p.opacity *= st.opacity;
if st.blend != vectorcraft_color::BlendMode::Normal {
p.blend = st.blend;
+90 -38
View File
@@ -37,7 +37,7 @@ fn all_points(n: &Node, out: &mut Vec<Point>) {
#[test]
fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
let out = stroke_pieces(&calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 1);
let b = bounds(&out);
assert!(near(b.height(), 10.0, 0.05), "{b:?}");
@@ -46,30 +46,30 @@ fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
#[test]
fn calligraphic_flat_nib_is_thin_along_its_angle() {
let out = stroke_pieces(&calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 2.0, 0.05), "{:?}", bounds(&out));
}
#[test]
fn calligraphic_width_at_45_degrees_matches_ellipse_support() {
let (a, b) = (5.0_f64, 1.0_f64);
let out = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let expect = 2.0 * ((a * a + b * b) / 2.0).sqrt();
assert!(near(bounds(&out).height(), expect, 0.05), "{} vs {expect}", bounds(&out).height());
// A vertical stroke with the same nib has the same width (45° is symmetric).
let v = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
let v = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
assert!(near(bounds(&v).width(), expect, 0.05));
}
#[test]
fn calligraphic_scales_with_stroke_weight() {
let out = stroke_pieces(&calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
assert!(near(bounds(&out).height(), 6.0, 0.05));
}
#[test]
fn round_nib_is_direction_independent() {
let out = stroke_pieces(&calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
let b = bounds(&out);
// Round caps: the bounds grow by the radius on every side.
assert!(near(b.x0, -2.0, 0.05) && near(b.y1, 82.0, 0.05), "{b:?}");
@@ -78,7 +78,7 @@ fn round_nib_is_direction_independent() {
#[test]
fn closed_calligraphic_makes_a_ring() {
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
let pd = out[0].path_data().unwrap();
assert_eq!(pd.subpaths.len(), 2);
let poly_area = |sp: &vectorcraft_geom::SubPath| {
@@ -102,12 +102,14 @@ fn rect_art(w: f64, h: f64) -> Node {
#[test]
fn art_brush_stretches_to_path_length() {
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
let b = bounds(&out);
assert!(near(b.x0, 20.0, 1e-6) && near(b.x1, 220.0, 1e-6), "{b:?}");
assert!(near(b.height(), 4.0, 1e-6));
// 3 pt stroke → 3× wider.
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
assert!(near(bounds(&out).height(), 12.0, 1e-6));
}
@@ -120,7 +122,7 @@ fn art_brush_arc_length_mapping_is_linear() {
let mut spacer = frame.clone();
spacer.appearance = Appearance::default().into();
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]);
let out = stroke_pieces(&art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let g = &out[0];
let m = g.children().unwrap()[1].geometric_bounds().unwrap();
assert!(near(m.x0, 150.0, 1e-6) && near(m.x1, 180.0, 1e-6), "{m:?}");
@@ -128,7 +130,8 @@ fn art_brush_arc_length_mapping_is_linear() {
#[test]
fn art_brush_proportional_keeps_aspect() {
let out = stroke_pieces(&art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 8.0, 1e-6));
}
@@ -139,7 +142,12 @@ fn art_brush_between_guides_keeps_ends() {
Node::path(NodeId(0), shapes::rectangle(Rect::new(80.0, -4.0, 100.0, 4.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let shaft = rect_art(80.0, 2.0);
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(shaft), std::sync::Arc::new(head)]);
let out = stroke_pieces(&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let out = stroke_pieces(
&Document::new(1.0, 1.0),
&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }),
&line(0.0, 0.0, 300.0, 0.0),
&stroke(1.0),
);
let h = out[0].children().unwrap()[1].geometric_bounds().unwrap();
assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}");
}
@@ -154,7 +162,7 @@ fn art_brush_follows_curves() {
bp_open.push(*el);
}
}
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
let mut pts = vec![];
all_points(&out[0], &mut pts);
assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len());
@@ -178,13 +186,13 @@ fn scatter_brush(spacing: f64) -> Brush {
#[test]
fn scatter_count_and_spacing() {
let out = stroke_pieces(&scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 10);
let centres: Vec<f64> = out.iter().map(|n| n.geometric_bounds().unwrap().center().x).collect();
for (i, c) in centres.iter().enumerate() {
assert!(near(*c, 5.0 + 10.0 * i as f64, 1e-6), "{centres:?}");
}
let out = stroke_pieces(&scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 5);
}
@@ -201,8 +209,8 @@ fn scatter_is_deterministic_and_randomised() {
}),
};
let p = line(0.0, 0.0, 300.0, 0.0);
let a1 = stroke_pieces(&b, &p, &stroke(1.0));
let a2 = stroke_pieces(&b, &p, &stroke(1.0));
let a1 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
let a2 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
assert_eq!(a1, a2);
let ys: Vec<f64> = a1.iter().map(|n| n.geometric_bounds().unwrap().center().y).collect();
assert!(ys.iter().any(|y| y.abs() > 0.5), "scatter offsets copies: {ys:?}");
@@ -228,21 +236,21 @@ fn pattern_brush(fit: PatternFit, corners: bool) -> Brush {
#[test]
fn pattern_tile_count_by_fit() {
let p = line(0.0, 0.0, 100.0, 0.0);
assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
let p2 = line(0.0, 0.0, 104.0, 0.0);
let st = stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
let st = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
assert_eq!(st.len(), 10);
assert!(near(bounds(&st).width(), 104.0, 1e-6), "stretch fills the path exactly");
let sp = stroke_pieces(&pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
let sp = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
assert_eq!(sp.len(), 10);
assert!(near(sp[0].geometric_bounds().unwrap().width(), 10.0, 1e-6), "add space keeps tile size");
assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
}
#[test]
fn pattern_places_corner_tiles() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
let corners = out
.iter()
.filter(
@@ -257,7 +265,7 @@ fn pattern_places_corner_tiles() {
#[test]
fn bristle_makes_translucent_strands() {
let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) };
let out = stroke_pieces(&b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
assert!(out.len() >= 5, "{}", out.len());
assert!(out.iter().all(|n| n.opacity < 1.0 && n.opacity > 0.0));
let bb = bounds(&out);
@@ -270,7 +278,7 @@ fn tints_colorization_uses_stroke_colour() {
let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") };
a.colorization = Colorization::Tints;
let b = Brush { name: "t".into(), kind: BrushKind::Art(a) };
let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
assert_eq!(out[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let mut white = rect_art(1.0, 1.0);
white.appearance.set_fill(Paint::solid(Color::WHITE));
@@ -310,7 +318,7 @@ fn brushes_serde_round_trip() {
fn every_default_brush_draws_something() {
let bp = preview_path(120.0, 24.0);
for b in defaults() {
let out = stroke_pieces(b, &bp, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), b, &bp, &stroke(1.0));
assert!(!out.is_empty(), "{}", b.name);
let bb = bounds(&out);
assert!(bb.width() > 50.0 && bb.width() < 200.0, "{}: {bb:?}", b.name);
@@ -365,20 +373,20 @@ fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
fn pressure_maps_to_size_along_the_stroke() {
let b = pressure_calli(10.0, 4.0);
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
// The stroke weight scales it.
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
// Kept within the valid range: 2 ± 4 never goes below nothing.
let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
}
@@ -389,7 +397,7 @@ fn pressure_varies_angle_and_roundness() {
let horizontal = line(0.0, 0.0, 100.0, 0.0);
let draw = |c: Calligraphic, p: f64| {
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
};
use Variation::{Fixed, Pressure};
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
@@ -399,7 +407,7 @@ fn pressure_varies_angle_and_roundness() {
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
let wide = |p| {
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
};
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
@@ -421,12 +429,18 @@ fn brushes_without_modes_vary_randomly_as_before() {
..old.clone()
};
let bp = preview_path(120.0, 24.0);
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0)));
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &explicit, &bp, &stroke(1.0))
);
// Fixed ignores the variation; Random still draws a different size per stroke.
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
assert!(near(bounds(&stroke_pieces(&Document::new(1.0, 1.0), &fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
// Recorded pressure changes nothing for a brush that doesn't use it.
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])));
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))
);
// Modes are saved by name.
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
@@ -438,7 +452,7 @@ fn junk_pressure_brushes_draw_finite_art() {
let bp = preview_path(120.0, 24.0);
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile));
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(size, var), &bp, &pressed(profile));
let mut pts = vec![];
for n in &out {
all_points(n, &mut pts);
@@ -461,8 +475,9 @@ fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
#[test]
fn scatter_values_follow_their_modes() {
let bp = line(0.0, 0.0, 400.0, 0.0);
let heights =
|b: &Brush, st: &StrokeLayer| -> Vec<f64> { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() };
let heights = |b: &Brush, st: &StrokeLayer| -> Vec<f64> {
stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect()
};
// Fixed: every copy at 50 %, whatever the second value.
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6)));
// Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt).
@@ -478,7 +493,10 @@ fn scatter_values_follow_their_modes() {
let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") };
assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]);
let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() };
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0)));
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &unsaved, &bp, &stroke(1.0))
);
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
}
@@ -521,3 +539,37 @@ fn art_colors_list_the_most_used_first() {
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
}
/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed
/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay.
#[test]
fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
use vectorcraft_doc::{PatternDef, Symbol};
let mut doc = Document::new(100.0, 100.0);
let mut brushed = rect_art(10.0, 4.0);
brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) }));
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] {
doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) });
doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)]));
}
let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY });
let painted = |pattern: &str| {
let fill = Paint::Pattern { pattern: pattern.into(), xf: vectorcraft_geom::Affine::IDENTITY };
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 4.0)), Appearance::basic(fill, Paint::None, 0.0))
};
let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into());
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) };
let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(!out.is_empty());
for piece in &out {
let children = piece.children().unwrap();
let symbols: Vec<&str> = children
.iter()
.filter_map(|c| if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { Some(symbol.as_str()) } else { None })
.collect();
assert_eq!(symbols, ["plain"]);
let fills: Vec<Paint> =
children.iter().filter(|c| c.path_data().is_some()).filter_map(|c| c.appearance.fill().map(|f| f.paint.clone())).collect();
assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]);
}
}
+1
View File
@@ -214,6 +214,7 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?;
document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) };
warnings.extend(damaged);
document.limit_depth_noting(&mut warnings);
Ok(Imported { document, warnings })
}
+21
View File
@@ -651,6 +651,27 @@ fn inserts_become_symbol_instances_with_their_transform() {
assert_eq!(stroke_rgb(art), [255, 0, 0, 255], "the block's own colour");
}
#[test]
fn an_insert_is_measured_clicked_and_marqueed_by_its_blocks_art() {
let inserts = [(0, "INSERT"), (8, "Doors"), (2, "Door"), (10, "100"), (20, "50"), (41, "2"), (42, "3"), (50, "90")];
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
let [inst] = &leaves(&doc)[..] else { panic!() };
let h = height(&doc);
// The line, 20 units up from the insertion point: a box with no width at x 100. The 20 × 20
// square placed by the insert's transform would be a 60 × 40 box from x 70 to 130.
let b = doc.bounds_of(&[inst.id], false).unwrap();
assert!(close(b, Rect::new(100.0, h - 70.0, 100.0, h - 50.0), 1e-9), "{b:?}");
// A click 25 pt right of the line, inside that square's box, selects nothing; one on the line
// selects the insert.
let hit = |x: f64| vectorcraft_doc::hit::hit_test(&doc, Point::new(x, h - 60.0), vectorcraft_doc::hit::HitOptions::default()).map(|h| h.leaf);
assert_eq!(hit(125.0), None);
assert_eq!(hit(100.0), Some(inst.id));
// A marquee beside the line leaves it out; one across it takes it.
let marquee = |r: Rect| vectorcraft_doc::hit::marquee(&doc, r, None, false);
assert!(marquee(Rect::new(115.0, h - 65.0, 125.0, h - 55.0)).is_empty());
assert_eq!(marquee(Rect::new(90.0, h - 62.0, 110.0, h - 58.0)), vec![inst.id]);
}
#[test]
fn by_block_colours_make_a_symbol_per_look() {
// The block's line takes the insert's colour: red and green make two symbols; a third red
+7 -3
View File
@@ -8,7 +8,7 @@ use std::io::Cursor;
use kurbo::PathEl;
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, TextKind, TextObject, Unit};
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
@@ -158,6 +158,9 @@ struct LayerFlags {
pub(crate) struct Scene<'a> {
doc: &'a Document,
/// The art boxes of `doc`'s symbols, which the walk measures symbol instances by when it
/// crops to the region ([`SymbolBoxes::quick`]).
symbols: SymbolBoxes<'a>,
o: &'a DxfOptions,
/// Document space → drawing units (y up, origin at the region's bottom-left).
to_dxf: Affine,
@@ -192,6 +195,7 @@ impl<'a> Scene<'a> {
let r = o.region;
Self {
doc,
symbols: doc.symbol_boxes().quick(),
o,
to_dxf: Affine::new([k, 0.0, 0.0, -k, -r.x0 * k, r.y1 * k]),
k,
@@ -271,7 +275,7 @@ impl<'a> Scene<'a> {
return;
}
if self.o.crop && !layer {
match n.visual_bounds() {
match n.visual_bounds_with(&self.symbols) {
Some(b) if !overlaps(b, self.o.region) => return,
None if !n.is_container() => return,
_ => {}
@@ -502,7 +506,7 @@ impl<'a> Scene<'a> {
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?;
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st))
}
/// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser
+23
View File
@@ -125,6 +125,18 @@ fn every_version_declares_its_acadver_and_is_well_formed() {
assert_eq!(DxfVersion::from_id("2019"), None);
}
#[test]
fn r12_viewport_has_no_duplicate_group_codes() {
let d = shapes_doc();
let out = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let vport = table(&out, "VPORT").into_iter().find(|o| kind(o) == "VPORT").expect("VPORT table record");
let codes: Vec<i32> = vport.iter().map(|pair| pair.0).collect();
let unique: std::collections::HashSet<_> = codes.iter().collect();
assert_eq!(unique.len(), codes.len(), "R12 VPORT record has duplicate group codes: {vport:?}");
let z = vport.iter().find(|pair| pair.0 == 36).map(|pair| pair.1.trim().parse::<f64>().unwrap());
assert_eq!(z, Some(1.0), "the view direction looks down the z axis");
}
#[test]
fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
let d = shapes_doc();
@@ -442,3 +454,14 @@ fn crop_leaves_out_art_outside_the_region() {
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
}
#[test]
fn crop_keeps_an_instance_whose_art_reaches_the_region() {
// The symbol's red rectangle, (100, 50) to (140, 90), drawn from (-50, -50): on the artboard,
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]);
let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 50.0, 140.0, 90.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
let o = DxfOptions { crop: true, ..opts(&d) };
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
}
+2 -1
View File
@@ -441,7 +441,8 @@ impl Drawing {
c.xy(13, 0.0, 0.0);
c.xy(14, 1.0, 1.0);
c.xy(15, 10.0, 10.0);
c.xyz(16, 0.0, 0.0);
// The view direction looks down the z axis: (0, 0, 1), each code once.
c.xy(16, 0.0, 0.0);
c.num(36, 1.0);
c.xyz(17, 0.0, 0.0);
let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
+50 -1
View File
@@ -249,7 +249,7 @@ pub(super) fn builtin_rgb_bytes(name: &str) -> Result<Vec<u8>, CmsError> {
/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible.
pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
let mut bytes = aligned(p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?);
// Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each).
if let Some(date) = bytes.get_mut(24..36) {
date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat());
@@ -257,6 +257,55 @@ pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
Ok(bytes)
}
/// `icc` with every tagged element starting on a 4-byte boundary, padded with zeros, as ICC.1
/// §7.1.2 requires (moxcms packs them, so a 62-byte `desc` left the next tag unaligned, #1584).
/// Tags that share data keep sharing it. A profile whose tag table doesn't read is returned as it is.
fn aligned(icc: Vec<u8>) -> Vec<u8> {
const TABLE: usize = 128;
let be = |b: &[u8], at: usize| b.get(at..at + 4).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize);
let Some(n) = be(&icc, TABLE).filter(|n| *n <= 1000) else { return icc };
let data_start = TABLE + 4 + 12 * n;
let mut entries = Vec::with_capacity(n);
for i in 0..n {
let e = TABLE + 4 + 12 * i;
let (Some(off), Some(size)) = (be(&icc, e + 4), be(&icc, e + 8)) else { return icc };
if off < data_start || off.checked_add(size).is_none_or(|end| end > icc.len()) {
return icc;
}
entries.push((off, size));
}
if entries.iter().all(|(off, _)| off.is_multiple_of(4)) && icc.len().is_multiple_of(4) {
return icc;
}
let mut out = icc.get(..data_start).map(<[u8]>::to_vec).unwrap_or_default();
let mut moved: Vec<((usize, usize), usize)> = Vec::new();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| entries[i].0);
for i in order {
let (off, size) = entries[i];
let at = match moved.iter().find(|(k, _)| *k == (off, size)) {
Some((_, at)) => *at,
None => {
out.resize(out.len().next_multiple_of(4), 0);
let at = out.len();
out.extend_from_slice(&icc[off..off + size]);
moved.push(((off, size), at));
at
}
};
let e = TABLE + 4 + 12 * i + 4;
out[e..e + 4].copy_from_slice(&(at as u32).to_be_bytes());
}
out.resize(out.len().next_multiple_of(4), 0);
let len = out.len() as u32;
out[0..4].copy_from_slice(&len.to_be_bytes());
// The profile ID (an MD5 of the old bytes) no longer matches: zero is "not computed".
if let Some(id) = out.get_mut(84..100) {
id.fill(0);
}
out
}
fn text_tag(s: &str) -> Option<ProfileText> {
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
}
+11 -1
View File
@@ -13,7 +13,7 @@ pub mod generic;
pub mod icc;
pub mod lab;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use serde::{Deserialize, Serialize};
@@ -165,6 +165,8 @@ fn generic() -> Arc<GenericCmyk> {
}
static USER: RwLock<Vec<Arc<IccProfile>>> = RwLock::new(Vec::new());
/// Counts the profiles [`register_icc`] has registered ([`profiles_generation`]).
static PROFILES_GENERATION: AtomicU64 = AtomicU64::new(0);
fn builtin_rgb_cache(name: &str) -> Option<Arc<IccProfile>> {
static CACHE: Mutex<Vec<Arc<IccProfile>>> = Mutex::new(Vec::new());
@@ -251,9 +253,17 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
u.push(Arc::new(p));
drop(u);
clear_proof_cache();
PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed);
Ok(info)
}
/// A number that changes whenever [`register_icc`] registers a profile: conversions through a
/// profile looked up by name, such as those of a [`ProofTarget::Cmyk`] proof, give the same colors
/// as long as the number stays the same.
pub fn profiles_generation() -> u64 {
PROFILES_GENERATION.load(Ordering::Relaxed)
}
/// Profile `name` as an ICC file to embed in an export: any RGB or CMYK profile of [`profiles`]
/// (by its current or legacy name), or [`GRAY`]. Built-in profiles are written in code from the
/// CMS: the RGB spaces from their primaries, the CMYK spaces as lookup tables sampled from their
+23
View File
@@ -111,3 +111,26 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
assert_eq!(abs(true), abs(false));
}
/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and
/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space.
#[test]
fn written_profiles_align_their_tags() {
for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] {
let icc = super::icc_bytes(name).unwrap();
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
assert_eq!(be(0), icc.len(), "{name}: header size");
assert_eq!(icc.len() % 4, 0, "{name}: {} bytes", icc.len());
let n = be(128);
for i in 0..n {
let (off, size) = (be(132 + 12 * i + 4), be(132 + 12 * i + 8));
assert_eq!(off % 4, 0, "{name}: tag {i} at {off}");
assert!(off + size <= icc.len());
}
let back = moxcms::ColorProfile::new_from_slice(&icc).unwrap();
let orig = moxcms::ColorProfile::new_srgb();
if name == super::SRGB {
assert_eq!((back.red_colorant, back.green_colorant, back.blue_colorant), (orig.red_colorant, orig.green_colorant, orig.blue_colorant));
}
}
}
+4 -2
View File
@@ -24,8 +24,10 @@ pub struct Clip {
}
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
/// for each): deeper ones are left out.
pub const MAX_NEST: usize = 128;
/// for each): deeper ones are left out. As deep as a document's objects may nest
/// ([`crate::MAX_DEPTH`]) under a layer, around the art; clips in groups that deep together are
/// merged by [`Document::limit_depth`].
pub const MAX_NEST: usize = crate::MAX_DEPTH - 2;
/// The warning for clips left out for nesting deeper than [`MAX_NEST`].
pub fn deep_clips() -> String {
+69 -18
View File
@@ -136,38 +136,57 @@ fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
Inks::new(w, h, data)
}
/// A CMYK TIFF with an alpha channel (5 samples) as RGBA pixels, each colour converted by `rgb`
/// (ink amounts 0..1 → sRGB 0..1) and associated (premultiplied) alpha divided back out; `None`
/// for other images, planar ones and ones larger than [`MAX_CMYK_PIXELS`]. The image decoder
/// doesn't read these at all; the inks can't be kept with the transparency, so they show as RGB.
pub fn cmyka_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other
/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`].
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
if !matches!(kind, tiff::ColorType::CMYKA(8 | 16)) || planar {
let planar = d.find_tag_unsigned::<u16>(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
// 0: white is zero, 1: black is zero (the two grayscales).
let photometric = d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten();
// ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha.
let extra = d.find_tag_unsigned_vec::<u16>(Tag::ExtraSamples).ok().flatten().and_then(|v| v.first().copied());
// (samples in a pixel, how many of them are its colour, is the next one alpha?)
let (samples, colour, alpha) = match kind {
tiff::ColorType::CMYKA(8 | 16) => (5, 4, true),
tiff::ColorType::Multiband { bit_depth: 8 | 16, num_samples: n @ 2.. } if matches!(photometric, Some(0 | 1)) => {
(usize::from(n), 1, matches!(extra, Some(1 | 2)))
}
_ => return None,
};
if planar {
return None;
}
let n = usize::try_from(pixels(w, h)?).ok()?;
// ExtraSamples 1: associated alpha (premultiplied colour); 2 (or none stated): unassociated.
let associated = d.find_tag_unsigned_vec::<u16>(tiff::tags::Tag::ExtraSamples).ok().flatten().is_some_and(|v| v.first() == Some(&1));
let data: Vec<u8> = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
if data.len() != n * 5 {
if Some(data.len()) != n.checked_mul(samples) {
return None;
}
let mut out = Vec::with_capacity(n * 4);
for p in data.as_chunks::<5>().0 {
let a = f32::from(p[4]) / 255.0;
let ink = |v: u8| {
for p in data.chunks_exact(samples) {
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
let a = f32::from(opacity) / 255.0;
let level = |v: u8| {
let v = f32::from(v) / 255.0;
if associated && a > 0.0 { (v / a).min(1.0) } else { v }
if extra == Some(1) && a > 0.0 { (v / a).min(1.0) } else { v }
};
let [r, g, b] = match *p {
[c, m, y, k, ..] if colour == 4 => rgb([level(c), level(m), level(y), level(k)]),
[v, ..] if photometric == Some(0) => [1.0 - level(v); 3],
[v, ..] => [level(v); 3],
[] => continue,
};
let [r, g, b] = rgb([ink(p[0]), ink(p[1]), ink(p[2]), ink(p[3])]);
out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
out.push(p[4]);
out.push(opacity);
}
image::RgbaImage::from_raw(w, h, out)
}
@@ -187,12 +206,44 @@ mod tests {
img.write_data(&[255, 0, 0, 0, 255, 0, 0, 0, 128, 128]).unwrap();
let bytes = file.into_inner();
let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)];
let px = cmyka_tiff_rgba(&bytes, naive).expect("CMYKA reads");
let px = extra_samples_tiff_rgba(&bytes, naive).expect("CMYKA reads");
assert_eq!(px.dimensions(), (2, 1));
assert_eq!(px.get_pixel(0, 0).0, [0, 255, 255, 255], "cyan, opaque");
assert_eq!(px.get_pixel(1, 0).0, [0, 0, 0, 128], "the premultiplied K divided back out: black at half alpha");
// Not CMYKA: not this reader's.
assert!(cmyka_tiff_rgba(&ImageBlob::cmyk_tiff(&inks()).unwrap().bytes, naive).is_none());
assert!(extra_samples_tiff_rgba(&ImageBlob::cmyk_tiff(&inks()).unwrap().bytes, naive).is_none());
}
/// Two 8-bit samples a pixel: gray and one extra sample, which the file's `ExtraSamples` names.
struct GrayExtra8;
impl tiff::encoder::colortype::ColorType for GrayExtra8 {
type Inner = u8;
const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::BlackIsZero;
const BITS_PER_SAMPLE: &'static [u16] = &[8, 8];
const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 2];
fn horizontal_predict(row: &[u8], result: &mut Vec<u8>) {
result.extend_from_slice(row);
}
}
/// A grayscale TIFF with an alpha channel, or with a channel that isn't one (#1466): the image
/// decoder reads neither.
#[test]
fn a_grayscale_tiff_with_an_extra_channel_reads_as_rgba() {
let gray = |extra: u16| {
let mut file = Cursor::new(Vec::new());
let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap();
let mut img = enc.new_image::<GrayExtra8>(3, 1).unwrap();
img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[extra][..]).unwrap();
// White opaque, a gray of 64 at 80% alpha (premultiplied: 51), black with no alpha.
img.write_data(&[255, 255, 51, 204, 0, 0]).unwrap();
let bytes = file.into_inner();
assert!(image::load_from_memory(&bytes).is_err(), "the image decoder reads it now: this reader may go");
extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).expect("gray with an extra sample reads").into_raw()
};
assert_eq!(gray(1), [255, 255, 255, 255, 64, 64, 64, 204, 0, 0, 0, 0], "associated alpha, divided back out");
assert_eq!(gray(2), [255, 255, 255, 255, 51, 51, 51, 204, 0, 0, 0, 0], "unassociated alpha");
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
}
fn inks() -> Inks {
+588
View File
@@ -0,0 +1,588 @@
//! How deep objects may nest.
//!
//! The tree is walked by recursion everywhere (drawing, bounds, hit tests, saving, exporting), so
//! a tree of unbounded depth overflows the stack: an abort no guard catches. No document deeper
//! than [`MAX_DEPTH`] is ever kept, nor one whose symbols and patterns draw deeper than
//! [`MAX_DRAWN_DEPTH`] (a symbol holding an instance of itself, or a pattern painting its own
//! tile, draws without end): importers and the native reader merge deeper objects into the group
//! around them and leave such instances and paints out
//! ([`Document::limit_depth`], with [`merged_note`] and [`symbols_note`] as their warnings), and
//! the engine refuses an edit that would nest deeper ([`Document::too_deep`]).
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_color::Paint;
use crate::{Document, Node, NodeKind};
/// The deepest an object may be: a top-level layer is 1 deep, what it holds 2, and so on (the art
/// of an opacity mask is one deeper than the object it masks). A symbol's or pattern's art counts
/// from 1 again.
///
/// A native file writes three levels of JSON for each level of objects, and its reader stops at
/// 128 levels: 32 leaves the deepest object 29 of them for its own data, so every document that
/// can be made can be saved and opened again.
pub const MAX_DEPTH: usize = 32;
/// The deepest an object may be drawn: as [`MAX_DEPTH`], the art of a symbol counted under its
/// instance and a pattern's tile under the object it paints (and what those use, under them).
/// Symbols a few objects deep nest in one another dozens deep within it.
pub const MAX_DRAWN_DEPTH: usize = 4 * MAX_DEPTH;
/// The warning of a file whose objects were nested deeper than [`MAX_DEPTH`].
pub fn merged_note() -> String {
format!("objects nested more than {MAX_DEPTH} deep were merged into the group around them")
}
/// The warning of a file with symbols or patterns that use themselves or are drawn deeper than
/// [`MAX_DRAWN_DEPTH`].
pub fn symbols_note() -> String {
format!(
"symbol instances and pattern paints inside their own symbol or pattern, or drawn more than {MAX_DRAWN_DEPTH} objects deep, were left out"
)
}
/// Run `f` where recursion has room: on a thread of its own with a 16 MB stack (the main thread
/// has 1 MB on Windows), or here where there are no threads (the web) or none can be started. For
/// readers whose recursion is capped but heavy; a panic in `f` goes on to the caller.
pub fn with_deep_stack<T: Send>(f: impl Fn() -> T + Sync) -> T {
#[cfg(not(target_arch = "wasm32"))]
{
let threaded = std::thread::scope(|s| {
let thread = std::thread::Builder::new().stack_size(16 << 20).spawn_scoped(s, &f).ok()?;
Some(thread.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)))
});
if let Some(done) = threaded {
return done;
}
}
f()
}
/// Is anything in `now` (each `depth` deep) deeper than [`MAX_DEPTH`], or `over` (given its
/// depth)? Without recursion: the trees may be of any depth. With `before` (the same list before
/// an edit, within the caps), only what the edit changed is looked at: a subtree still shared with
/// it, in the same place, is as it was.
fn deeper(now: &[Arc<Node>], before: Option<&[Arc<Node>]>, depth: usize, over: &dyn Fn(&Node, usize) -> bool) -> bool {
type Pending<'a> = Vec<(&'a Node, Option<&'a Node>, usize)>;
fn push<'a>(stack: &mut Pending<'a>, now: &'a [Arc<Node>], before: Option<&'a [Arc<Node>]>, depth: usize) {
for (i, n) in now.iter().enumerate() {
let was = before.and_then(|b| b.get(i));
if !was.is_some_and(|w| Arc::ptr_eq(w, n)) {
stack.push((n, was.map(|w| &**w), depth));
}
}
}
let mut stack = vec![];
push(&mut stack, now, before, depth);
while let Some((n, was, d)) = stack.pop() {
if d > MAX_DEPTH || over(n, d) {
return true;
}
if let Some(ch) = n.children() {
push(&mut stack, ch, was.and_then(Node::children).map(Vec::as_slice), d + 1);
}
if let Some(m) = &n.extras().mask {
let was = was.and_then(|w| w.extras().mask.as_ref()).map(|m| std::slice::from_ref(&m.art));
push(&mut stack, std::slice::from_ref(&m.art), was, d + 1);
}
}
false
}
/// [`deeper`] for the nesting alone.
fn nested_deeper(now: &[Arc<Node>], depth: usize) -> bool {
deeper(now, None, depth, &|_, _| false)
}
/// Merge what is deeper than [`MAX_DEPTH`] in `nodes` (each `depth` deep) into the group around
/// it: a container at the cap is replaced by what it holds (a clipping group's clipping path and
/// an opacity mask too deep are left out); what it held stays hidden or locked as it was. Returns
/// whether anything changed. Recurses no deeper than the cap.
fn limit(nodes: &mut Vec<Arc<Node>>, depth: usize) -> bool {
let mut changed = false;
let mut i = 0;
while let Some(n) = nodes.get_mut(i) {
if !nested_deeper(std::slice::from_ref(n), depth) {
i += 1;
continue;
}
changed = true;
let n = Arc::make_mut(n);
if n.extras().mask.as_ref().is_some_and(|m| nested_deeper(std::slice::from_ref(&m.art), depth + 1)) {
n.extras_mut().mask = None;
}
if depth < MAX_DEPTH {
if let Some(ch) = n.children_mut() {
limit(ch, depth + 1);
}
i += 1;
continue;
}
let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
let (visible, locked) = (n.visible, n.locked);
let mut held = n.children_mut().map(std::mem::take).unwrap_or_default();
if clip && !held.is_empty() {
held.remove(0);
}
for c in &mut held {
if (!visible && c.visible) || (locked && !c.locked) {
let c = Arc::make_mut(c);
c.visible &= visible;
c.locked |= locked;
}
}
// What it held takes its place, and is looked at in its turn.
nodes.splice(i..=i, held);
}
changed
}
/// A symbol (`false`) or a pattern (`true`) of the document, by name: what art draws by name, an
/// instance its symbol's art and a pattern paint its tile's.
type Def<'a> = (bool, &'a str);
/// How deep each symbol's and pattern's art is drawn, the art of the symbols and patterns it uses
/// included (drawn under the object that uses them): `None` for one that uses itself, in its art
/// or through what that uses, or is drawn deeper than [`MAX_DRAWN_DEPTH`].
type Heights<'a> = HashMap<Def<'a>, Option<usize>>;
/// What `n` itself draws by name: the symbol it is an instance of, the patterns that paint it.
fn uses(n: &Node) -> impl Iterator<Item = Def<'_>> {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
_ => None,
};
let patterns = crate::swatches::node_paints(n).filter_map(|p| match p {
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
_ => None,
});
symbol.into_iter().chain(patterns)
}
/// [`Heights`] of the document's symbols and patterns. Without recursion: they may use one
/// another in a chain of any length. Their own art is within [`MAX_DEPTH`].
fn heights(doc: &Document) -> Heights<'_> {
// Each one's own depth, and what its art uses with the deepest use of each.
type Own<'a> = (usize, Vec<(Def<'a>, usize)>);
let arts = (doc.symbols.iter().map(|s| ((false, s.name.as_str()), std::slice::from_ref(&s.art))))
.chain(doc.patterns.iter().map(|p| ((true, p.name.as_str()), &p.art[..])));
let mut own: HashMap<Def, Own> = HashMap::new();
for (def, art) in arts {
let (mut deepest, mut used) = (0, HashMap::<Def, usize>::new());
let mut stack: Vec<(&Node, usize)> = art.iter().map(|n| (&**n, 1)).collect();
while let Some((n, d)) = stack.pop() {
deepest = deepest.max(d);
for u in uses(n) {
let at = used.entry(u).or_default();
*at = (*at).max(d);
}
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1)));
}
// (The first of two of one name is the one drawn.)
own.entry(def).or_insert((deepest, used.into_iter().collect()));
}
// Depth first over what uses what. One met again while it is being worked out (`None` in its
// entry) uses itself.
let mut found: HashMap<Def, Option<Option<usize>>> = HashMap::new();
let starts: Vec<Def> = own.keys().copied().collect();
for start in starts {
if found.contains_key(&start) {
continue;
}
// (which, the next of its uses to look at, the deepest found so far: `None` once it can't be drawn)
let mut path: Vec<(Def, usize, Option<usize>)> = vec![(start, 0, own.get(&start).map(|o| o.0))];
found.insert(start, None);
while let Some(&(def, next, deepest)) = path.last() {
let used = own.get(&def).map_or(&[][..], |o| &o.1);
let Some(&(other, at)) = used.get(next) else {
// Everything it uses is worked out.
let deepest = deepest.filter(|d| *d <= MAX_DRAWN_DEPTH);
found.insert(def, Some(deepest));
path.pop();
if let Some((user, next, held)) = path.last_mut() {
let at = own.get(user).and_then(|o| o.1.get(next.wrapping_sub(1))).map_or(1, |u| u.1);
*held = held.zip(deepest).map(|(h, d)| h.max(at + d));
}
continue;
};
let known = found.get(&other).copied();
if known.is_none() && own.contains_key(&other) {
found.insert(other, None);
}
let Some((_, next, deepest)) = path.last_mut() else { break };
*next += 1;
match known {
// Not in the document: it draws nothing.
None if !own.contains_key(&other) => {}
None => path.push((other, 0, own.get(&other).map(|o| o.0))),
Some(Some(Some(d))) => *deepest = deepest.map(|h| h.max(at + d)),
// It uses itself (it is on the way down here), or can't be drawn.
Some(None | Some(None)) => *deepest = None,
}
}
}
found.into_iter().map(|(def, h)| (def, h.flatten())).collect()
}
/// Can't `used`, by an object `depth` deep, be drawn there?
fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool {
heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH))
}
/// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object):
/// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees
/// within [`MAX_DEPTH`].
fn strip(nodes: &mut Vec<Arc<Node>>, depth: usize, bad: &dyn Fn(Def, usize) -> bool) -> bool {
let any = |n: &Node, d: usize| uses(n).any(|u| bad(u, d));
let before = nodes.len();
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if bad((false, symbol.as_str()), depth)));
let mut changed = nodes.len() != before;
for n in nodes.iter_mut() {
if !deeper(std::slice::from_ref(n), None, depth, &any) {
continue;
}
changed = true;
let n = Arc::make_mut(n);
if uses(n).any(|u| bad(u, depth)) {
for p in crate::swatches::node_paints_mut(n) {
if matches!(p, Paint::Pattern { pattern, .. } if bad((true, pattern.as_str()), depth)) {
*p = Paint::None;
}
}
}
// An opacity mask whose art is such an instance goes with it.
let mut mask = n.extras().mask.as_ref().map(|m| vec![m.art.clone()]);
if let Some(art) = &mut mask
&& strip(art, depth + 1, bad)
{
match art.pop() {
Some(art) => {
if let Some(m) = n.extras_mut().mask.as_mut() {
m.art = art;
}
}
None => n.extras_mut().mask = None,
}
}
if let Some(ch) = n.children_mut() {
strip(ch, depth + 1, bad);
}
}
changed
}
impl Document {
/// Is anything nested deeper than [`MAX_DEPTH`], or drawn deeper than [`MAX_DRAWN_DEPTH`]
/// (a symbol or pattern that uses itself is)? With `before` (this document before an edit,
/// itself within the caps), only what the edit changed is looked at.
pub fn too_deep(&self, before: Option<&Document>) -> bool {
fn symbol(d: &Document, i: usize) -> Option<&[Arc<Node>]> {
d.symbols.get(i).map(|s| std::slice::from_ref(&s.art))
}
fn pattern(d: &Document, i: usize) -> Option<&[Arc<Node>]> {
d.patterns.get(i).map(|p| &p.art[..])
}
let nested = |_: &Node, _: usize| false;
if (0..self.symbols.len()).any(|i| symbol(self, i).is_some_and(|art| deeper(art, before.and_then(|b| symbol(b, i)), 1, &nested)))
|| (0..self.patterns.len()).any(|i| pattern(self, i).is_some_and(|art| deeper(art, before.and_then(|b| pattern(b, i)), 1, &nested)))
{
return true;
}
if self.symbols.is_empty() && self.patterns.is_empty() {
return deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &nested);
}
let heights = heights(self);
if heights.values().any(Option::is_none) {
return true;
}
// Symbols and patterns draw as deep as before when none changed; else every use of them
// is looked at again.
let same = |x: &[Arc<Node>], y: &[Arc<Node>]| x.len() == y.len() && x.iter().zip(y).all(|(x, y)| Arc::ptr_eq(x, y));
let before = before.filter(|b| {
b.symbols.len() == self.symbols.len()
&& b.patterns.len() == self.patterns.len()
&& b.symbols.iter().zip(&self.symbols).all(|(x, y)| x.name == y.name && Arc::ptr_eq(&x.art, &y.art))
&& b.patterns.iter().zip(&self.patterns).all(|(x, y)| x.name == y.name && same(&x.art, &y.art))
});
let over = |n: &Node, d: usize| uses(n).any(|u| undrawable(&heights, u, d));
deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &over)
}
/// [`Self::limit_depth`] → whether objects were merged, and whether uses of symbols and
/// patterns were left out.
fn limit_depths(&mut self) -> (bool, bool) {
let mut merged = limit(&mut self.layers, 1);
for s in &mut self.symbols {
// The art itself is 1 deep, so it stays the one object it is.
let mut art = vec![s.art.clone()];
if limit(&mut art, 1)
&& let Some(one) = art.pop()
{
s.art = one;
merged = true;
}
}
for p in &mut self.patterns {
merged |= limit(&mut p.art, 1);
}
if self.symbols.is_empty() && self.patterns.is_empty() {
return (merged, false);
}
// The symbols and patterns that can't be drawn lose what their art uses: each is then as
// deep as its own art, and what uses it can be drawn.
let (symbols, patterns): (Vec<usize>, Vec<usize>) = {
let heights = heights(self);
let bad = |def: Def| heights.get(&def).is_some_and(Option::is_none);
(
(0..self.symbols.len()).filter(|i| self.symbols.get(*i).is_some_and(|s| bad((false, s.name.as_str())))).collect(),
(0..self.patterns.len()).filter(|i| self.patterns.get(*i).is_some_and(|p| bad((true, p.name.as_str())))).collect(),
)
};
let mut left_out = !(symbols.is_empty() && patterns.is_empty());
let all = |_: Def, _: usize| true;
for i in symbols {
let Some(s) = self.symbols.get_mut(i) else { continue };
let mut art = vec![s.art.clone()];
strip(&mut art, 1, &all);
// (An instance alone was the whole art: an empty group stands in.)
s.art = art.pop().unwrap_or_else(|| Arc::new(Node::group(s.art.id, vec![])));
}
for i in patterns {
if let Some(p) = self.patterns.get_mut(i) {
strip(&mut p.art, 1, &all);
}
}
// Then the uses drawn too deep where they are.
let mut layers = self.layers.clone();
left_out |= {
let heights = heights(self);
strip(&mut layers, 1, &|used, depth| undrawable(&heights, used, depth))
};
self.layers = layers;
(merged, left_out)
}
/// Bring the document within the caps: merge the objects nested deeper than [`MAX_DEPTH`]
/// into the group around them, and leave out the uses of symbols and patterns that can't be
/// drawn (inside their own symbol or pattern, or deeper than [`MAX_DRAWN_DEPTH`]). What an
/// importer does to what it read. Returns whether anything changed.
pub fn limit_depth(&mut self) -> bool {
let (merged, left_out) = self.limit_depths();
merged || left_out
}
/// [`Self::limit_depth`], adding [`merged_note`] and [`symbols_note`] to an importer's
/// `warnings` for what it did.
pub fn limit_depth_noting(&mut self, warnings: &mut Vec<String>) {
let (merged, left_out) = self.limit_depths();
warnings.extend(merged.then(merged_note));
warnings.extend(left_out.then(symbols_note));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{NodeId, OpacityMask};
/// One path in `levels` groups, each holding a second path beside the next group.
fn nested(levels: usize) -> Arc<Node> {
let mut id = 0;
let mut next = || {
id += 1;
NodeId(id)
};
let path = |id| Arc::new(Node::path(id, Default::default(), Default::default()));
let mut inner = path(next());
for _ in 0..levels {
inner = Arc::new(Node::group(next(), vec![inner, path(next())]));
}
inner
}
fn doc(art: Arc<Node>) -> Document {
let mut d = Document::new(100.0, 100.0);
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.push(art);
}
d
}
#[test]
fn a_document_at_the_cap_is_left_alone() {
// The layer, the groups and the path in them: MAX_DEPTH deep.
let mut d = doc(nested(MAX_DEPTH - 2));
let before = d.clone();
assert!(!d.too_deep(None));
assert!(!d.limit_depth());
assert_eq!(d, before);
}
#[test]
fn deeper_objects_are_merged_into_the_group_around_them() {
for levels in [MAX_DEPTH - 1, MAX_DEPTH + 5, 100_000] {
let mut d = doc(nested(levels));
assert!(d.too_deep(None), "{levels}");
assert!(d.limit_depth(), "{levels}");
assert!(!d.too_deep(None), "{levels}");
// Every path is kept, under the layer and the groups down to the cap.
assert_eq!(d.node_count(), 1 + (MAX_DEPTH - 2) + (levels + 1), "{levels}");
assert!(!d.limit_depth(), "nothing more to merge");
}
}
#[test]
fn a_clipping_path_and_a_mask_too_deep_are_left_out_and_hidden_art_stays_hidden() {
let mut inner = Node::new(NodeId(9000), NodeKind::Group { children: vec![nested(0), nested(0)], clip: true });
inner.visible = false;
let mut art = Arc::new(inner);
for i in 0..MAX_DEPTH {
art = Arc::new(Node::group(NodeId(9001 + i as u64), vec![art]));
}
let mut masked = Node::group(NodeId(8000), vec![]);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new((*nested(MAX_DEPTH)).clone(), true)));
let mut d = doc(art);
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.push(Arc::new(masked));
}
assert!(d.limit_depth());
assert!(!d.too_deep(None));
let mut leaves = vec![];
d.walk(|n| {
if n.children().is_none() {
leaves.push(n.visible);
}
});
assert_eq!(leaves, [false], "the clipping path is gone, what it clipped is still hidden");
assert!(d.node(NodeId(8000)).is_some_and(|n| n.extras().mask.is_none()));
}
#[test]
fn an_edit_is_checked_by_what_it_changed() {
let before = doc(nested(MAX_DEPTH - 2));
let mut wrapped = before.clone();
if let Some(ch) = wrapped.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
let all = std::mem::take(ch);
ch.push(Arc::new(Node::group(NodeId(7000), all)));
}
assert!(wrapped.too_deep(Some(&before)), "the same art, one deeper");
let mut beside = before.clone();
if let Some(ch) = beside.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.push(nested(3));
}
assert!(!beside.too_deep(Some(&before)));
assert!(!before.too_deep(Some(&before)));
}
#[test]
fn symbols_and_patterns_are_limited_too() {
let mut d = Document::new(10.0, 10.0);
d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) });
assert!(d.too_deep(None));
assert!(d.limit_depth());
assert!(!d.too_deep(None));
assert!(d.symbols.first().is_some_and(|s| s.art.children().is_some()), "still one object");
}
fn instance(id: u64, of: &str) -> Arc<Node> {
Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: of.into(), xf: Default::default() }))
}
/// `count` symbols, each `levels` groups around an instance of the next; the last one's of `last`.
fn symbols(count: usize, levels: usize, last: &str) -> Document {
let mut d = doc(instance(1, "s0"));
for i in 0..count {
let mut art = instance(2, &if i + 1 < count { format!("s{}", i + 1) } else { last.to_string() });
for _ in 0..levels {
art = Arc::new(Node::group(NodeId(3), vec![art]));
}
d.symbols.push(crate::Symbol { name: format!("s{i}"), art });
}
d
}
fn instances(d: &Document) -> usize {
let mut n = 0;
let mut count = |x: &Node| n += usize::from(matches!(x.kind, NodeKind::SymbolInstance { .. }));
d.walk(&mut count);
d.symbols.iter().for_each(|s| s.art.walk(&mut count));
n
}
#[test]
fn symbols_in_symbols_are_drawn_only_so_deep() {
// Three objects deep each (two groups and the instance), under the instance on the layer.
let fits = MAX_DRAWN_DEPTH / 3 - 1;
let mut d = symbols(fits, 2, "none");
assert!(!d.too_deep(None) && !d.limit_depth());
let mut d = symbols(fits + 2, 2, "none");
assert!(d.too_deep(None));
let before = instances(&d);
assert!(d.limit_depth() && !d.too_deep(None));
assert!(instances(&d) < before && instances(&d) > 0, "only the instances drawn too deep go");
// A chain of any length is measured, not walked by recursion.
let mut d = symbols(100_000, 0, "none");
assert!(d.too_deep(None) && d.limit_depth() && !d.too_deep(None));
}
#[test]
fn a_symbol_holding_itself_loses_the_instance() {
for (count, last) in [(1, "s0"), (3, "s0"), (3, "s1")] {
let mut d = symbols(count, 1, last);
assert!(d.too_deep(None), "{count} {last}");
let mut warnings = vec![];
d.limit_depth_noting(&mut warnings);
assert_eq!(warnings, [symbols_note()]);
assert!(!d.too_deep(None), "{count} {last}");
assert!(d.symbols.iter().all(|s| s.art.children().is_some()), "the art is still a group");
}
// An edit that makes one hold itself is seen, whatever else stayed as it was.
let before = symbols(2, 1, "none");
let mut after = before.clone();
after.symbols[1].art = Arc::new(Node::group(NodeId(9), vec![instance(8, "s0")]));
assert!(!before.too_deep(None) && after.too_deep(Some(&before)));
}
/// A path painted with the pattern named `with`.
fn painted(id: u64, with: &str) -> Arc<Node> {
let fill = Paint::Pattern { pattern: with.into(), xf: Default::default() };
Arc::new(Node::path(NodeId(id), Default::default(), crate::Appearance::basic(fill, Paint::None, 0.0)))
}
fn pattern_fills(d: &Document) -> usize {
let mut n = 0;
d.visit_paints(&mut |p| n += usize::from(matches!(p, Paint::Pattern { .. })));
n
}
#[test]
fn a_pattern_painting_its_own_tile_loses_the_paint() {
// One that paints its own tile; two that paint each other's; one in a symbol it holds.
let itself = |d: &mut Document| d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "p")]));
let each_other = |d: &mut Document| {
d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")]));
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "p")]));
};
let through_a_symbol = |d: &mut Document| {
d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")]));
d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") });
};
for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] {
let mut d = doc(painted(1, "p"));
make(&mut d);
assert!(d.too_deep(None));
let mut warnings = vec![];
d.limit_depth_noting(&mut warnings);
assert_eq!(warnings, [symbols_note()]);
assert!(!d.too_deep(None));
assert_eq!(pattern_fills(&d), 1, "the object on the layer keeps its pattern");
}
// A pattern whose tile is painted with another is fine.
let mut d = doc(painted(1, "p"));
d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")]));
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")]));
assert!(!d.too_deep(None) && !d.limit_depth());
}
}
+306 -43
View File
@@ -1,9 +1,11 @@
//! Hit testing over the document tree.
use std::collections::HashSet;
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
use vectorcraft_geom::{Point, Rect};
use crate::{Document, Node, NodeId, NodeKind};
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HitKind {
@@ -31,6 +33,10 @@ impl Hit {
/// The object the Selection tool selects: the child of the layer (the outermost group).
/// Inside isolation mode (`scope`), the child of the isolated container instead.
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
// A single object isolated: it is the only object.
if scope == Some(self.leaf) {
return self.leaf;
}
// The innermost of the isolated container and an envelope whose contents are edited.
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
if let Some(i) = at(scope).max(at(self.contents_of)) {
@@ -79,6 +85,79 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
// Normally one hit test per *object* suffices: layers and sublayers aren't selectable,
// while a group (including a clipping group) is selected as one object. Repeatedly
// searching the full tree for each hit made a stack of n overlapping paths O(n^2).
// Isolation and Edit Contents change where selection groups begin; keep the original
// traversal for those cases rather than change their Select Behind semantics.
if opt.scope.is_some() {
return objects_at_repeated(doc, p, opt, scope);
}
let mut tops = Vec::new();
let mut seen = HashSet::new();
let symbols = doc.symbol_boxes();
for layer in doc.layers.iter().rev() {
// Malformed files may contain non-layer roots; let the general traversal handle them.
if !layer.is_layer() {
return objects_at_repeated(doc, p, opt, scope);
}
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
continue;
}
if !objects_at_layer(layer, p, opt, &symbols, &mut tops, &mut seen) {
// An envelope in Edit Contents may expose objects *within* a top-level group.
// The repeated traversal handles that mixed hierarchy precisely.
return objects_at_repeated(doc, p, opt, scope);
}
}
tops
}
/// One traversal through a layer and its sublayers. Each selectable object's subtree is
/// inspected once (only until its first hit). `false` asks the caller to use the general
/// algorithm for an envelope whose contents are currently being edited. Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
let Some(children) = layer.children() else { return true };
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
return true;
}
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
for (i, c) in children.iter().enumerate().rev() {
if !may_hit(layer, i, c, p, opt, symbols, true) || seen.contains(&c.id) {
continue;
}
if c.is_layer() {
if !objects_at_layer(c, p, opt, symbols, tops, seen) {
return false;
}
continue;
}
// Editing an envelope selects its contents rather than the containing object. Even
// when nested in a group, the old traversal may select both a member and the group.
if c.is_container() {
let mut editing = false;
c.walk(&mut |n| editing |= edits_contents(n));
if editing {
return false;
}
}
let skip = |id| seen.contains(&id);
let mut chain = Vec::new();
let hit = match &c.kind {
NodeKind::Group { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some(),
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt, symbols).is_some(),
_ => hit_leaf(c, p, opt, symbols).is_some(),
};
if hit && seen.insert(c.id) {
tops.push(c.id);
}
}
true
}
/// The general selection order also handles isolation and nested Edit Contents.
fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let mut tops: Vec<NodeId> = vec![];
// Each round leaves out the objects found so far (and everything inside them).
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id), true).map(|h| h.top_object(scope)) {
@@ -90,21 +169,29 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
tops
}
/// [`hit_test`] passing over the nodes `skip` names (and their contents).
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
let finish = |mut h: Hit| {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
h
};
let symbols = doc.symbol_boxes();
// Isolation mode: only what is inside the isolated container hits. The art around it is
// locked, and clicks pass through it (#833).
if editable_only
&& let Some(scope) = opt.scope
&& let Some(container) = doc.node(scope).filter(|n| n.is_container())
&& let Some(isolated) = doc.node(scope)
&& let Some(mut chain) = doc.ancestry(scope)
{
return hit_children(container, p, opt, skip, &mut chain, editable_only).map(finish);
if isolated.is_container() {
return hit_children(isolated, p, opt, &symbols, skip, &mut chain, editable_only).map(finish);
}
// A single object isolated (a path, an image…): only it hits, among its siblings.
chain.pop();
let parent = chain.last().and_then(|id| doc.node(*id))?;
return hit_children(parent, p, opt, &symbols, &|id| id != scope || skip(id), &mut chain, editable_only).map(finish);
}
let mut chain = Vec::new();
for layer in doc.layers.iter().rev() {
@@ -117,7 +204,7 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
}
chain.push(layer.id);
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
if let Some(h) = hit_children(layer, p, opt, skip, &mut chain, editable_only) {
if let Some(h) = hit_children(layer, p, opt, &symbols, skip, &mut chain, editable_only) {
return Some(finish(h));
}
chain.pop();
@@ -140,10 +227,29 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
}
/// 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) {
return false;
}
// Read-only samples can read locked/template art; hidden art never participates.
if !c.visible || editable_only && (c.locked || c.is_template()) {
return false;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c)
|| c.extras().shaper.is_some() && source_isolated(c)
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
}
fn hit_children(
parent: &Node,
p: Point,
opt: HitOptions,
symbols: &SymbolBoxes,
skip: &dyn Fn(NodeId) -> bool,
chain: &mut Vec<NodeId>,
editable_only: bool,
@@ -157,36 +263,21 @@ fn hit_children(
return None;
}
for (i, c) in children.iter().enumerate().rev() {
if parent.extras().shaper.is_some() {
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
if (i == 0) != construction {
continue;
}
}
// Read-only samples can read locked/template art; hidden art never participates.
if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) {
continue;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
if !(edits_contents(c)
|| c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
&& let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
if !may_hit(parent, i, c, p, opt, symbols, editable_only) || skip(c.id) {
continue;
}
chain.push(c.id);
let hit = match &c.kind {
// A sublayer whose Preview is off is clicked in outline, like an outline view.
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, skip, chain, editable_only),
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, skip, chain, editable_only),
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, symbols, skip, chain, editable_only),
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only),
// Edit Contents: the envelope's content hits, undistorted.
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, skip, chain, editable_only),
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only),
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
// as the blend.
NodeKind::Blend { .. } => hit_children(c, p, opt, skip, chain, editable_only)
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
NodeKind::Blend { .. } => hit_children(c, p, opt, symbols, skip, chain, editable_only)
.or_else(|| hit_leaf(c, p, opt, symbols).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 })),
_ => hit_leaf(c, p, opt, symbols).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
};
if hit.is_some() {
return hit;
@@ -206,7 +297,7 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
match &n.kind {
NodeKind::Path { path, rule, .. } => {
let bp = path.to_bezpath();
@@ -236,8 +327,8 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
| NodeKind::PlacedDocument(_)
| NodeKind::SymbolInstance { .. }
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. } => n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt)),
| NodeKind::Envelope { .. } => n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds),
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
if stroke_contains(&bp, 0.0, opt.tol, p) {
@@ -272,18 +363,23 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
}
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
fn hit_any(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
match &n.kind {
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt)),
_ => hit_leaf(n, p, opt),
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
_ => hit_leaf(n, p, opt, symbols),
}
}
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect.
/// 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`]).
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) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
Some(s) => match s.children() {
Some(c) => c.iter().collect(),
// 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(),
},
// The objects of the layers, looking through sublayers (hidden, locked and template ones
// are left out).
None => doc
@@ -306,38 +402,44 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
out
}
fn touches(n: &Node, r: Rect) -> bool {
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> 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, r))
children.iter().any(|c| c.visible && touches(c, s, r))
}
_ => n.geometric_bounds().is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.origin())),
// 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| {
let crosses = (b.width() == 0.0 || b.height() == 0.0) && b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1;
b.intersect(r).area() > 0.0 || r.contains(b.origin()) || crosses
}),
}
}
fn collect_leaves(n: &Node, r: Rect, out: &mut Vec<NodeId>) {
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, 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, r, out);
collect_leaves(c, s, r, out);
}
}
}
_ => {
if touches(n, r) {
if touches(n, s, r) {
out.push(n.id);
}
}
}
}
let symbols = doc.symbol_boxes();
for t in tops {
if !t.visible || t.locked {
continue;
}
if leaves {
collect_leaves(t, r, &mut out);
} else if touches(t, r) {
collect_leaves(t, &symbols, r, &mut out);
} else if touches(t, &symbols, r) {
out.push(t.id);
}
}
@@ -605,6 +707,167 @@ world",
assert!(at(90.0, None).is_empty());
}
/// Many overlapping paths: Select Behind must list each exactly once, front to back.
/// This is quadratic with repeated full-document hit tests, but linear in tree size with
/// the per-object walk.
#[test]
fn objects_at_large_overlapping_stack() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = Vec::new();
for _ in 0..256 {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
ids.push(id);
}
ids.reverse();
assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids);
}
/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping,
/// invisibility, and path/outline-only settings.
#[test]
fn objects_at_one_pass_matches_repeated_hits() {
let mut d = Document::new(200.0, 200.0);
let layer = d.layers[0].id;
let add = |d: &mut Document, parent, bounds| {
let id = d.alloc_id();
d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap();
id
};
add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0));
let sub = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap();
add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0));
let group = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap();
add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0));
add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0));
let clip = d.alloc_id();
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art());
let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art());
let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]);
clipping.set_clips(true);
d.insert(Some(layer), usize::MAX, clipping).unwrap();
for (point, opt) in [
(Point::new(25.0, 25.0), HitOptions::default()),
(Point::new(3.0, 25.0), HitOptions::default()),
(Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }),
(Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }),
(Point::new(100.0, 100.0), HitOptions::default()),
] {
assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}");
}
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind {
*preview = false;
}
let p = Point::new(25.0, 25.0);
let opt = HitOptions::default();
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
d.node_mut(group).unwrap().locked = true;
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
}
/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends
/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects
/// in the same order as the hit test repeated over what is left.
#[test]
fn objects_at_matches_repeated_hits_on_random_documents() {
let mut seed = 0x9e37_79b9_u32;
let mut next = move |n: u32| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed % n
};
fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) {
for _ in 0..2 + next(4) {
let id = d.alloc_id();
let (x, y) = (f64::from(next(60)), f64::from(next(60)));
let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50)));
let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() };
let mut n = match next(if depth < 3 { 8 } else { 4 }) {
4 | 5 => Node::group(id, Vec::new()),
6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)),
7 => {
let mut g = Node::group(id, Vec::new());
g.set_clips(true);
g
}
_ => Node::path(id, shapes::rectangle(rect), appearance),
};
n.visible = next(8) != 0;
n.locked = next(8) == 0;
if let NodeKind::Layer { preview, .. } = &mut n.kind {
*preview = next(3) != 0;
}
let container = n.is_container();
// A sublayer belongs in a layer; in a group it is a group like another to the hit test.
d.insert(Some(parent), usize::MAX, n).unwrap();
if container {
fill(d, id, depth + 1, next);
}
}
}
for round in 0..200 {
let mut d = Document::new(120.0, 120.0);
let layer = d.layers[0].id;
fill(&mut d, layer, 0, &mut next);
for _ in 0..12 {
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() };
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
}
}
}
/// Select Behind finds a symbol instance by its symbol's art, as a click does: art from
/// (100, 60) to (140, 80), outside the 20 × 20 square around the origin, drawn where it lies by
/// one instance and 100 pt lower by an instance in a group.
#[test]
fn objects_at_finds_symbol_instances_by_their_art() {
let mut d = Document::new(200.0, 200.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) });
let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id());
let instance =
|id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) });
d.insert(Some(layer), 0, instance(top, 0.0)).unwrap();
d.insert(Some(layer), 1, Node::group(group, vec![Arc::new(instance(inner, 100.0))])).unwrap();
let opt = HitOptions::default();
for (x, y, want) in [(120.0, 70.0, vec![top]), (120.0, 170.0, vec![group]), (0.0, 0.0, vec![]), (0.0, 100.0, vec![])] {
let p = Point::new(x, y);
assert_eq!(objects_at(&d, p, opt, None), want, "{p:?}");
assert_eq!(objects_at_repeated(&d, p, opt, None), want, "{p:?}");
}
}
/// Edit Contents can expose descendants of a top-level group: preserve the original
/// selection order by falling back to the general hit traversal.
#[test]
fn objects_at_edit_contents_uses_general_order() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()));
let (a, b) = (d.alloc_id(), d.alloc_id());
let e = Node::new(
d.alloc_id(),
NodeKind::Envelope {
content: vec![inner(a), inner(b)],
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
fidelity: 100.0,
editing: true,
options: Default::default(),
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
},
);
d.insert(Some(layer), usize::MAX, e).unwrap();
let p = Point::new(25.0, 25.0);
let opt = HitOptions::default();
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
assert_eq!(objects_at(&d, p, opt, None), vec![b, a]);
}
#[test]
fn locked_and_hidden_skip() {
let mut d = Document::new(200.0, 200.0);
+267 -22
View File
@@ -11,6 +11,7 @@ pub mod blend;
pub mod clipnest;
pub mod cmyk;
pub mod corners;
pub mod depth;
pub mod graph;
pub mod hit;
mod id_index;
@@ -30,6 +31,7 @@ pub mod overprint;
pub mod pattern;
pub mod perspective;
mod pixels;
pub use pixels::MAX_RASTER_ALLOC;
pub mod placed_document;
pub mod profiles;
pub mod puppet;
@@ -43,9 +45,12 @@ pub mod shaper;
pub mod slices;
pub mod style_libs;
pub mod swatches;
mod symbols;
pub use symbols::SymbolBoxes;
pub mod text;
pub mod trace;
pub mod variables;
pub mod xml_id;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -76,6 +81,7 @@ pub use appearance::{
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use depth::MAX_DEPTH;
pub use graph::{
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
ValueAxisSide,
@@ -693,6 +699,10 @@ fn yes() -> bool {
true
}
/// 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)>>;
pub const FORMAT_VERSION: u32 = 1;
impl Document {
@@ -766,7 +776,9 @@ impl Document {
/// Allocate a fresh node id.
pub fn alloc_id(&mut self) -> NodeId {
let id = NodeId(self.next_id);
self.next_id += 1;
// Never reached from a counter [`Self::fix_next_id`] has seen (it stays under
// [`MAX_NEXT_ID`]); saturating so that no counter can overflow.
self.next_id = self.next_id.saturating_add(1);
id
}
pub fn peek_next_id(&self) -> u64 {
@@ -774,14 +786,22 @@ impl Document {
}
/// Ensure `next_id` is above every id in the tree (after deserializing foreign data).
pub fn fix_next_id(&mut self) {
// Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a
// counter up there: new ids start under them and never reach them, where counting on from
// the largest would run out and hand out ids the document already uses.
let mut max = 0;
let mut see = |id: u64| {
if id < MAX_NEXT_ID {
max = max.max(id);
}
};
for l in &self.layers {
l.walk(&mut |n| max = max.max(n.id.0));
l.walk(&mut |n| see(n.id.0));
}
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
// An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst.
self.next_id = self.next_id.max(max.saturating_add(1));
self.slices.iter().for_each(|s| see(s.id.0));
self.assets.iter().for_each(|a| see(a.id));
let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0);
self.next_id = counter.max(max.saturating_add(1));
}
/// Find a node anywhere in the tree.
@@ -1021,6 +1041,128 @@ impl Document {
Some((parent, idx, n))
}
/// Remove the nodes `ids` names and return them in the order of `ids`, in one pass over the
/// children of each parent. Each id is listed once and none lies inside another listed node, as
/// in a list of top-level objects. For an id that is not in the tree, is listed twice or lies
/// inside another listed node, it fails with `NoNode` and leaves the document as it was.
pub fn remove_many(&mut self, ids: &[NodeId]) -> Result<Vec<Arc<Node>>, DocError> {
let places = self.places(ids)?;
let removed = self.take(places, ids.len())?;
self.index.clear();
Ok(removed)
}
/// Remove the nodes `ids` names, as [`Document::remove_many`] does, and put the node that `make`
/// builds from them (in the order of `ids`) in the parent of the last of them, at that node's
/// index less the number of listed nodes below it there. Returns the new node's id.
pub fn wrap(&mut self, ids: &[NodeId], make: impl FnOnce(Vec<Arc<Node>>) -> Node) -> Result<NodeId, DocError> {
let places = self.places(ids)?;
let last = ids.len().checked_sub(1);
let found = places.iter().find_map(|(up, kids)| kids.iter().find(|(_, k)| Some(*k) == last).map(|(i, _)| (up.clone(), *i)));
let Some((up, i)) = found else { return Err(DocError::Invalid("nothing to wrap".into())) };
// The parent and each node it is in move down by the listed nodes before them.
let below = |parent: &[usize], index: usize| places.get(parent).map_or(0, |kids| kids.partition_point(|(j, _)| *j < index));
let to: Vec<usize> = up.iter().enumerate().map(|(l, &j)| j.saturating_sub(below(&up[..l], j))).collect();
let at = i.saturating_sub(below(&up, i));
let node = make(self.take(places, ids.len())?);
let id = node.id;
let children = self.children_at(&to).ok_or_else(|| DocError::Invalid("the container of the listed objects is gone".into()))?;
let at = at.min(children.len());
children.insert(at, Arc::new(node));
self.index.clear();
Ok(id)
}
/// Put each list of nodes in `items` directly above its anchor (`replace`: in its place), in
/// list order, in one pass over the children of each parent. Each anchor is listed once and none
/// lies inside another. For an anchor that is not in the tree, is listed twice or lies inside
/// another listed anchor, it fails with `NoNode` and leaves the document as it was.
pub fn insert_beside(&mut self, items: Vec<(NodeId, Vec<Node>)>, replace: bool) -> Result<(), DocError> {
let anchors: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
let places = self.places(&anchors)?;
let mut lists: Vec<Vec<Node>> = items.into_iter().map(|(_, nodes)| nodes).collect();
self.edit_children(places, |k, anchor, children| {
if !replace {
children.push(anchor);
}
let nodes = lists.get_mut(k).map(std::mem::take).unwrap_or_default();
children.extend(nodes.into_iter().map(Arc::new));
})?;
self.index.clear();
Ok(())
}
/// Where each of `ids` is ([`Places`]). Fails with `NoNode` for an id that is not in the tree,
/// is listed twice or lies inside another listed node.
fn places(&self, ids: &[NodeId]) -> Result<Places, DocError> {
let paths = self.index_paths(ids.iter().copied());
let mut places = Places::new();
for (k, id) in ids.iter().enumerate() {
let (&i, up) = paths.get(id).and_then(|p| p.split_last()).ok_or(DocError::NoNode(*id))?;
match places.get_mut(up) {
Some(kids) => kids.push((i, k)),
None => {
places.insert(up.to_vec(), vec![(i, k)]);
}
}
}
for kids in places.values_mut() {
kids.sort_unstable();
}
// An id listed twice has one path, so its parent lists the same child twice. A listed node
// lies inside another when its parent's path, or a part of it from the top, is the path of a
// listed node.
let twice = places.values().flat_map(|kids| kids.windows(2)).find(|w| w[0].0 == w[1].0).map(|w| w[1].1);
let listed = |path: &[usize]| {
path.split_last().is_some_and(|(i, up)| places.get(up).is_some_and(|kids| kids.binary_search_by_key(i, |(j, _)| *j).is_ok()))
};
let nested = places.iter().find(|(up, _)| (1..=up.len()).any(|l| listed(&up[..l])));
let inner = nested.and_then(|(_, kids)| kids.first()).map(|(_, k)| *k);
if let Some(id) = twice.or(inner).and_then(|k| ids.get(k)) {
return Err(DocError::NoNode(*id));
}
Ok(places)
}
/// Take the nodes `places` lists out of their parents and return them in the order of the list,
/// which is `len` long.
fn take(&mut self, places: Places, len: usize) -> Result<Vec<Arc<Node>>, DocError> {
let mut taken = vec![None; len];
self.edit_children(places, |k, node, _| {
if let Some(t) = taken.get_mut(k) {
*t = Some(node);
}
})?;
Ok(taken.into_iter().flatten().collect())
}
/// Rebuild the children of each parent in `places` once, the parents later in tree order
/// first. `each` gets each listed child, its place in the list and the parent's new children,
/// onto which it pushes what takes the child's place.
fn edit_children(&mut self, places: Places, mut each: impl FnMut(usize, Arc<Node>, &mut Vec<Arc<Node>>)) -> Result<(), DocError> {
for (up, kids) in places.into_iter().rev() {
let children = self.children_at(&up).ok_or_else(|| DocError::Invalid("a container of the listed objects is gone".into()))?;
let old = std::mem::take(children);
children.reserve(old.len());
let mut kids = kids.into_iter().peekable();
for (i, node) in old.into_iter().enumerate() {
match kids.next_if(|(j, _)| *j == i) {
Some((_, k)) => each(k, node, children),
None => children.push(node),
}
}
}
Ok(())
}
/// The children of the node at index path `path`, or the top-level layers for an empty path.
fn children_at(&mut self, path: &[usize]) -> Option<&mut Vec<Arc<Node>>> {
match path {
[] => Some(&mut self.layers),
_ => self.node_mut_at(path)?.children_mut(),
}
}
/// Default target layer: the topmost visible, unlocked layer.
pub fn default_layer(&self) -> Option<NodeId> {
self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id)
@@ -1081,30 +1223,36 @@ impl Document {
_ => LAYER_COLORS[0].1,
}
}
/// Union of the geometric bounds of `ids`.
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
/// instances measured by their art ([`Node::geometric_bounds_with`]).
pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option<Rect> {
ids.iter()
.filter_map(|id| self.node(*id))
.fold(None, |acc, n| vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds() } else { n.geometric_bounds() }))
let symbols = self.symbol_boxes();
ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| {
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) })
})
}
/// Bounds of all art.
/// Visual bounds of all art, with symbol instances measured by their art.
pub fn art_bounds(&self) -> Option<Rect> {
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds()))
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).
pub fn artboard_at(&self, p: Point) -> Option<usize> {
self.artboards.iter().rposition(|a| a.rect.contains(p))
}
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
/// sublayers) lying entirely inside it. Locked and hidden objects and layers stay put unless
/// `locked_and_hidden` (Selection & Anchor Display › Move Locked and Hidden Artwork with
/// Artboard).
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
/// hidden objects and layers stay put unless `locked_and_hidden` (Selection & Anchor Display ›
/// Move Locked and Hidden Artwork with Artboard).
pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec<NodeId> {
fn collect(n: &Node, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
// Art drawn flush with the artboard can end up a rounding error outside it (a background
// 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible.
let rect = rect.inflate(1e-6, 1e-6);
fn collect(n: &Node, s: &SymbolBoxes, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
if c.is_layer() {
collect(c, rect, all, out);
} else if let Some(b) = c.geometric_bounds()
collect(c, s, rect, all, out);
} else if let Some(b) = c.geometric_bounds_with(s)
// Inside it, edges included: art flush with the right or bottom edge is on it too
// (`Rect::contains` leaves those out, #890).
&& b.x0 >= rect.x0
@@ -1116,9 +1264,10 @@ impl Document {
}
}
}
let symbols = self.symbol_boxes();
let mut art = vec![];
for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
collect(l, rect, locked_and_hidden, &mut art);
collect(l, &symbols, rect, locked_and_hidden, &mut art);
}
art
}
@@ -1238,7 +1387,7 @@ impl Document {
self.guides.extend(copies);
}
pub fn next_artboard_id(&self) -> u32 {
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
free_id(self.artboards.iter().map(|a| a.id))
}
pub fn pattern(&self, name: &str) -> Option<&PatternDef> {
self.patterns.iter().find(|p| p.name == name)
@@ -1322,7 +1471,7 @@ impl Document {
}
/// An id no graphic style has.
pub fn next_graphic_style_id(&self) -> u32 {
self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1
free_id(self.graphic_styles.iter().map(|g| g.id))
}
/// The id of graphic style `index`, assigning one if it has none yet.
pub fn graphic_style_id(&mut self, index: usize) -> u32 {
@@ -1336,6 +1485,14 @@ impl Document {
pub fn new_graphic_style_name(&self) -> String {
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
}
/// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name.
pub fn unique_symbol_name(&self, base: &str) -> String {
let taken = |n: &str| self.symbols.iter().any(|s| s.name == n);
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> {
@@ -1353,11 +1510,29 @@ fn default_graphic_styles() -> Vec<GraphicStyle> {
.collect()
}
/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so
/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718).
const MAX_NEXT_ID: u64 = 1 << 62;
/// An id (never 0) none of `used` has: one past the largest, or, when that is the largest there
/// is (a damaged file), the smallest one free.
fn free_id(used: impl Iterator<Item = u32>) -> u32 {
let mut used: Vec<u32> = used.collect();
if let Some(next) = used.iter().max().copied().unwrap_or(0).checked_add(1) {
return next;
}
used.sort_unstable();
used.dedup();
// Ids 1, 2, 3… as far as they are all taken: the first that isn't follows them.
let taken = used.iter().filter(|&&id| id != 0).zip(1u32..).take_while(|&(&id, n)| id == n).count();
u32::try_from(taken).map_or(u32::MAX, |n| n.saturating_add(1))
}
impl Document {
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
pub fn reserve_ids(&mut self, next: u64) {
self.next_id = self.next_id.max(next);
self.next_id = self.next_id.max(next.min(MAX_NEXT_ID));
}
}
@@ -1405,6 +1580,37 @@ mod tests {
assert_eq!(d.art_on_artboard(wide, true), vec![a, b], "a locked layer's art too");
}
#[test]
fn art_on_artboard_tolerates_roundoff_at_each_edge() {
let (d, a, _) = doc_with_rects();
let roundoff = 1e-12;
for board in [
Rect::new(roundoff, 0.0, 10.0, 10.0),
Rect::new(0.0, roundoff, 10.0, 10.0),
Rect::new(0.0, 0.0, 10.0 - roundoff, 10.0),
Rect::new(0.0, 0.0, 10.0, 10.0 - roundoff),
] {
assert_eq!(d.art_on_artboard(board, false), vec![a], "roundoff at {board:?}");
}
// A real overhang remains outside.
for board in
[Rect::new(0.001, 0.0, 10.0, 10.0), Rect::new(0.0, 0.001, 10.0, 10.0), Rect::new(0.0, 0.0, 9.999, 10.0), Rect::new(0.0, 0.0, 10.0, 9.999)]
{
assert!(d.art_on_artboard(board, false).is_empty(), "overhang at {board:?}");
}
}
#[test]
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
let mut d = Document::new(612.0, 792.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap();
// The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't.
assert_eq!(d.art_on_artboard(Rect::new(0.0, 0.0, 15.0, 15.0), false), vec![id]);
}
#[test]
fn find_and_paths() {
let (d, a, b) = doc_with_rects();
@@ -1451,6 +1657,45 @@ mod tests {
assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1);
}
/// #1718: a file whose ids or counters are at their type's largest value still hands out ids
/// nothing in it uses (and never 0), instead of overflowing.
#[test]
fn exhausted_ids_in_a_file_dont_run_out() {
let (d, a, b) = doc_with_rects();
let mut v = serde_json::to_value(&d).unwrap();
v["next_id"] = serde_json::json!(u64::MAX);
// Object `a` gets the largest id there is.
fn renumber(v: &mut serde_json::Value, from: u64) {
match v {
serde_json::Value::Object(o) => {
if o.get("id").and_then(serde_json::Value::as_u64) == Some(from) {
o.insert("id".into(), serde_json::json!(u64::MAX));
}
o.values_mut().for_each(|c| renumber(c, from));
}
serde_json::Value::Array(a) => a.iter_mut().for_each(|c| renumber(c, from)),
_ => {}
}
}
renumber(&mut v["layers"], a.0);
let mut d: Document = serde_json::from_value(v).unwrap();
assert!(d.node(NodeId(u64::MAX)).is_some(), "the file's largest id is kept");
d.fix_next_id();
let fresh: Vec<NodeId> = (0..3).map(|_| d.alloc_id()).collect();
assert!(fresh.iter().all(|id| d.node(*id).is_none() && id.0 > b.0 && id.0 < MAX_NEXT_ID), "{fresh:?}");
assert!(fresh[0] != fresh[1] && fresh[1] != fresh[2]);
// A counter left at the end by other means stays there rather than wrapping to 0.
d.next_id = u64::MAX;
assert_eq!((d.alloc_id(), d.peek_next_id()), (NodeId(u64::MAX), u64::MAX));
d.reserve_ids(u64::MAX);
// Artboards and graphic styles: one past the largest, else the smallest id free.
assert_eq!(free_id([1, 2, 7].into_iter()), 8);
assert_eq!(free_id([u32::MAX, 0, 1, 2, 4].into_iter()), 3);
assert_eq!(free_id([u32::MAX].into_iter()), 1);
d.artboards[0].id = u32::MAX;
assert_eq!(d.next_artboard_id(), 1);
}
#[test]
fn serde_roundtrip() {
let (d, _, _) = doc_with_rects();
+54 -11
View File
@@ -318,8 +318,10 @@ impl WarpStyle {
}
}
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1).
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) {
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1);
/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real
/// proportions).
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) {
use WarpStyle::*;
let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom
let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides
@@ -328,14 +330,18 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) ->
if b.abs() < 1e-6 {
(x, y)
} else {
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°).
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in
// the box's real proportions (lengths in half-widths, so y counts `aspect` each):
// the edges are concentric arcs, and one that would pass the centre stops there
// instead of folding over (#1578).
let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
let sweep = b.abs() * PI;
let r0 = 2.0 / sweep;
let yy = if b > 0.0 { y } else { -y };
let yy = if b > 0.0 { y } else { -y } * k;
let a = x / r0;
let r = r0 - yy;
let r = (r0 - yy).max(0.0);
let (s, c) = a.sin_cos();
let (nx, ny) = (r * s, r0 - r * c);
let (nx, ny) = (r * s, (r0 - r * c) / k);
(nx, if b > 0.0 { ny } else { -ny })
}
}
@@ -1122,9 +1128,9 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
Box::new(move |u: f64, v: f64| {
let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0);
let (x2, y2) = if horizontal {
warp_point(st, b, dh, dv, x, y)
warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y)
} else {
let (a, b2) = warp_point(st, b, dh, dv, y, x);
let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x);
(b2, a)
};
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
@@ -1699,11 +1705,23 @@ pub fn subtree_has_live(n: &Node) -> bool {
any
}
/// Largest stroke outset in a subtree (for visual bounds of envelopes).
pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
/// Largest stroke outset in a subtree (for visual bounds of envelopes). With `symbols`, the
/// outsets in the art of the symbols its instances show count too
/// ([`crate::symbols::ArtBox::reach`]), grown by [`crate::SymbolBoxes::brush_pad`]. In boxes made
/// by [`crate::SymbolBoxes::without_art_strokes`], the outsets in the art do not count, and the pad
/// does.
pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxes>) -> f64 {
let mut o = 0.0f64;
for n in nodes {
n.walk(&mut |c| o = o.max(c.appearance.outset()));
n.walk(&mut |c| {
o = o.max(c.appearance.outset());
if let (Some(s), NodeKind::SymbolInstance { symbol, .. }) = (symbols, &c.kind)
&& let Some(art) = s.art(symbol)
{
let reach = if s.art_strokes() { art.reach } else { 0.0 };
o = o.max(reach + s.brush_pad(symbol));
}
});
}
o
}
@@ -1711,6 +1729,31 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
#[cfg(test)]
mod tests {
use super::*;
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
/// real distance from the centre line; an inner edge that would pass the centre stops there.
#[test]
fn arc_warp_is_round_in_real_proportions() {
for (aspect, b) in [(0.25, 0.1), (0.25, 0.5), (0.25, 1.0), (1.0, 0.5), (2.0, 0.3)] {
let r0 = 2.0 / (b * PI);
for (y, r) in [(-1.0, r0 + aspect), (1.0, (r0 - aspect).max(0.0))] {
for i in 0..=10 {
let x = -1.0 + 0.2 * f64::from(i);
let (nx, ny) = warp_point(WarpStyle::Arc, b, 0.0, 0.0, aspect, x, y);
let d = nx.hypot(ny * aspect - r0);
assert!((d - r).abs() < 1e-9, "aspect {aspect}, bend {b}, y {y}, x {x}: {d} vs {r}");
}
}
}
// At 100 % on a square the inner edge meets at the centre instead of folding over.
let (nx, _) = warp_point(WarpStyle::Arc, 1.0, 0.0, 0.0, 1.0, 0.8, 1.0);
assert!(nx.abs() < 1e-12);
// A square bent less than that is what it always was.
let (nx, ny) = warp_point(WarpStyle::Arc, 0.5, 0.0, 0.0, 1.0, 0.5, 0.25);
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
}
use vectorcraft_geom::shapes;
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
+93 -21
View File
@@ -11,6 +11,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use crate::appearance::Appearance;
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
use crate::pattern::RepeatSpec;
use crate::symbols::{SYMBOL_SQUARE, SymbolBoxes};
use crate::text::TextObject;
/// Stable per-document object id. Never reused.
@@ -977,6 +978,12 @@ impl Node {
_ => format!("<{}>", self.kind_label()),
}
}
/// [`Self::display_name`], or its XML id when Preferences › Units › Identify Objects By is XML ID.
pub fn identify_label(&self, xml_id: bool) -> String {
let name = self.display_name();
if xml_id { crate::xml_id::from_object_name(&name) } else { name }
}
/// Path data for path nodes.
pub fn path_data(&self) -> Option<&PathData> {
match &self.kind {
@@ -1004,52 +1011,101 @@ impl Node {
}
/// A quick box round everything the object can paint, for culling clicks: paths and compound
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
/// ([`Appearance::outset`]) instead of measuring along their shape.
/// ([`Appearance::outset`]) instead of measuring along their shape. Symbol instances are
/// measured as in [`Node::visual_bounds`].
pub fn reach_bounds(&self) -> Option<Rect> {
self.reach(None)
}
/// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from
/// `symbols`. An instance that only moves its art is measured as the art's visual box moved,
/// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`])
/// grown by the farthest a stroke in the art can reach.
pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
symbols.quickly(|| self.reach(Some(symbols)))
}
/// The box of [`Node::reach_bounds`], or of [`Node::reach_bounds_with`] when `symbols` is given.
pub(crate) fn reach(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
match &self.kind {
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))
self.geometric(symbols).map(|b| b.inflate(o, o))
}
_ => self.visual_bounds(),
_ => self.visual(symbols),
}
}
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path.
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path. A
/// symbol instance is measured as its transform applied to the 20 × 20 square around the origin
/// that symbols made in the app keep their art in ([`Node::geometric_bounds_with`] measures
/// its art).
pub fn geometric_bounds(&self) -> Option<Rect> {
self.geometric(None)
}
/// [`Node::geometric_bounds`] with each symbol instance measured as its transform applied to
/// its symbol's art box from `symbols`. An instance is measured as the square when its symbol
/// is missing, when its symbol or a symbol nested in its art holds an instance of itself, and
/// when symbols nest 32 deep in its art.
pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
self.geometric(Some(symbols))
}
/// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols`
/// is given.
pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
let union = |nodes: &[Arc<Node>]| nodes.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols)));
match &self.kind {
NodeKind::Path { path, .. } => path.bounds(),
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
children.first().and_then(|c| c.geometric_bounds())
children.first().and_then(|c| c.geometric(symbols))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
.iter()
.skip(usize::from(self.extras().shaper.is_some()))
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))),
NodeKind::Text(t) => self.projected(t.bounds()),
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
NodeKind::SymbolInstance { xf, .. } => self.projected(Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0)))),
NodeKind::Blend { children, spec } => {
let b = crate::live::nodes_bounds(children);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
NodeKind::SymbolInstance { symbol, xf } => {
let art = symbols.and_then(|s| s.art(symbol)).map_or(SYMBOL_SQUARE, |a| a.geometric);
self.projected(Some(xf.transform_rect_bbox(art)))
}
NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())),
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
NodeKind::Mesh(m) => m.bounds(),
NodeKind::Repeat(r) => r.bounds(),
// Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the
// copies out by the source's bounds measured without the document.
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?),
NodeKind::PlacedDocument(p) => Some(p.bounds()),
}
}
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]).
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]). A symbol
/// instance is measured as in [`Node::geometric_bounds`], grown by its own strokes' reach
/// ([`Node::visual_bounds_with`] measures its art).
pub fn visual_bounds(&self) -> Option<Rect> {
self.visual(None)
}
/// [`Node::visual_bounds`] with symbol instances measured by their symbol's art from
/// `symbols`, the strokes in it included. An instance that only moves its art is measured as
/// the art's visual box moved. Any other instance is measured as its art transformed by it with
/// the strokes at their own weight, as the renderer draws it, and never smaller than its
/// geometric box ([`Node::geometric_bounds_with`]). Instances inside symbol art are measured as
/// in [`Node::reach_bounds_with`]. In boxes made by [`SymbolBoxes::with_brush_art`], the box of
/// an instance whose art holds brushed strokes grows by as far as their brush art can paint. In
/// boxes made by [`SymbolBoxes::without_art_strokes`], an instance is measured as its art's
/// geometric box placed by its transform, grown by its own strokes.
pub fn visual_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
self.visual(Some(symbols))
}
/// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is
/// given.
pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// A clipping path that paints (see `clip_paint`) strokes over the clip edge.
let clip = children.first()?;
if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
clip.visual_bounds()
clip.visual(symbols)
} else {
clip.geometric_bounds()
clip.geometric(symbols)
}
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
@@ -1058,25 +1114,41 @@ impl Node {
children
.iter()
.skip(usize::from(self.extras().shaper.is_some()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)))
.map(|b| b.inflate(o, o))
}
NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()));
let o = crate::live::max_outset(children);
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)));
let o = crate::live::max_outset(children, symbols);
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()).map(|r| r.inflate(o, o)))
}
NodeKind::Envelope { content, .. } | NodeKind::Repeat(RepeatSpec { source: content, .. }) => {
let o = crate::live::max_outset(content);
self.geometric_bounds().map(|b| b.inflate(o, o))
let o = crate::live::max_outset(content, symbols);
self.geometric(symbols).map(|b| b.inflate(o, o))
}
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let b = self.geometric_bounds()?;
let b = self.geometric(symbols)?;
Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default()))
}
NodeKind::SymbolInstance { symbol, xf } => {
let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) {
Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric),
Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual),
Some((s, art)) => {
let placed = xf.transform_rect_bbox(art.geometric);
match s.placed_visual(symbol, *xf) {
Some(v) => v.union(placed),
None => placed.inflate(art.reach, art.reach),
}
}
None => xf.transform_rect_bbox(SYMBOL_SQUARE),
};
let o = self.appearance.outset() + symbols.map_or(0.0, |s| s.brush_pad(symbol));
self.projected(Some(b)).map(|b| b.inflate(o, o))
}
_ => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))
self.geometric(symbols).map(|b| b.inflate(o, o))
}
}
}
+26 -8
View File
@@ -5,7 +5,7 @@
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
use crate::{Document, Node, NodeId, NodeKind};
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
/// Angles closer than this (degrees) are the same.
const EPS_DEG: f64 = 1e-6;
@@ -86,11 +86,12 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
impl Node {
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
/// without changing it.
pub fn bounds_in(&self, a: Affine, visual: bool) -> Option<Rect> {
/// without changing it, symbol instances measured by their art from `symbols`
/// ([`Node::geometric_bounds_with`]).
pub fn bounds_in(&self, symbols: &SymbolBoxes, a: Affine, visual: bool) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: false } => {
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual)));
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(symbols, a, visual)));
// The group's own strokes paint around its members (as in `visual_bounds`).
let o = if visual { self.appearance.outset() } else { 0.0 };
b.map(|b| b.inflate(o, o))
@@ -98,12 +99,12 @@ impl Node {
NodeKind::Group { children, clip: true } => {
let clip = children.first()?;
let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_));
clip.bounds_in(a, visual && stroked)
clip.bounds_in(symbols, a, visual && stroked)
}
_ => {
let mut n = self.clone();
n.transform(a, false);
if visual { n.visual_bounds() } else { n.geometric_bounds() }
if visual { n.visual_bounds_with(symbols) } else { n.geometric_bounds_with(symbols) }
}
}
}
@@ -117,14 +118,16 @@ impl Document {
if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 }
}
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]):
/// geometric bounds, or `visual` with their strokes.
/// geometric bounds, or `visual` with their strokes, symbol instances measured by their art
/// ([`Document::bounds_of`]).
pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option<OrientedBox> {
let angle = self.bbox_angle(ids);
if angle == 0.0 {
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
}
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?;
let symbols = self.symbol_boxes();
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(&symbols, to_local, visual)))?;
Some(OrientedBox { angle, rect })
}
}
@@ -192,4 +195,19 @@ mod tests {
d.move_node(id, Some(g), 0).unwrap();
assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0);
}
#[test]
fn oriented_bounds_hug_a_rotated_symbol_instances_art() {
let mut d = Document::new(500.0, 500.0);
let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 10.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Bar".into(), art: std::sync::Arc::new(bar) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap();
let c = Point::new(120.0, 105.0);
d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false);
let b = d.oriented_bounds(&[id], false).unwrap();
assert!(close(b.angle, 30.0));
assert!(close(b.rect.width(), 40.0) && close(b.rect.height(), 10.0), "the bar, not the 20 × 20 square: {b:?}");
assert!(b.center().distance(c) < 1e-9);
}
}
+15 -2
View File
@@ -341,6 +341,15 @@ pub fn uses_pattern(n: &Node, name: Option<&str>) -> bool {
})
}
/// Transform Patterns without Transform Objects: `m` moves the pattern tiles of `n` and of
/// everything inside it, and the art stays where it is.
pub fn transform_patterns_only(n: &mut Node, m: Affine) {
transform_pattern_paints(n, m);
for c in n.children_mut().into_iter().flatten() {
transform_patterns_only(Arc::make_mut(c), m);
}
}
/// Apply `m` to the pattern placement of every pattern paint of `n` (Transform Patterns).
pub fn transform_pattern_paints(n: &mut Node, m: Affine) {
// Without pattern paints there is nothing to change: don't copy the shared appearance.
@@ -507,8 +516,12 @@ impl RepeatSpec {
}
/// Geometric bounds of all instances.
pub fn bounds(&self) -> Option<Rect> {
let b = self.source_bounds()?;
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(b))))
self.copies_bounds(self.source_bounds()?)
}
/// The bounds of all instances of a source measuring `source`, each placed by its transform
/// ([`Self::transforms`]).
pub(crate) fn copies_bounds(&self, source: Rect) -> Option<Rect> {
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
}
/// Apply a document transform (source + arrangement parameters).
pub fn transform(&mut self, a: Affine, scaling: impl Into<crate::node::Scaling>) {
+30 -2
View File
@@ -6,6 +6,22 @@ use std::sync::Arc;
use crate::ImageBlob;
/// The most memory the decoded RGBA pixels of one image may take: the allowance the importers
/// and the renderer decode within (a 60 in sheet at 300 ppi is ~475 MB). The web app has 4 GB to
/// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB.
pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded or would take
/// more than `max` bytes as RGBA. The size is read from the header first: the decoder's own limit
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) * 4 > max {
return None;
}
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
}
impl ImageBlob {
/// A PNG blob of `png` bytes.
pub fn png(png: Vec<u8>) -> Self {
@@ -21,7 +37,7 @@ impl ImageBlob {
/// pixels left alone, re-encoded as PNG. `f` runs once per distinct colour. `None` when the
/// image can't be decoded or no pixel changed.
pub fn map_rgb(&self, mut f: impl FnMut([u8; 3]) -> [u8; 3]) -> Option<ImageBlob> {
let mut img = image::load_from_memory(&self.bytes).ok()?.to_rgba8();
let mut img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
let mut memo: HashMap<[u8; 3], [u8; 3]> = HashMap::new();
let mut changed = false;
for px in img.pixels_mut().filter(|p| p.0[3] > 0) {
@@ -67,7 +83,7 @@ impl ImageBlob {
LAST.with_borrow_mut(|last| match last {
Some((k, img)) if *k == key => Some(img.clone()),
_ => {
let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8());
let img = Arc::new(rgba_within(&self.bytes, MAX_RASTER_ALLOC)?);
*last = Some((key, img.clone()));
Some(img)
}
@@ -115,6 +131,18 @@ mod tests {
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
}
/// #1717: the RGBA size is checked from the header, before anything is decoded: a grey image
/// the decoder's own limit lets through is four times that as RGBA.
#[test]
fn pixels_past_the_allowance_are_not_decoded() {
let grey = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
let mut png = Vec::new();
grey.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(rgba_within(&png, 31).is_none(), "8 grey bytes are 32 as RGBA");
assert_eq!(rgba_within(&png, 32).map(|i| i.dimensions()), Some((4, 2)));
assert!(rgba_within(b"not an image", u64::MAX).is_none());
}
#[test]
fn samples_a_pixel_or_the_average_around_it() {
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
+20 -1
View File
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
use serde::{Deserialize, Serialize};
use crate::{Document, NodeId};
use crate::{Document, NodeId, NodeKind};
/// (subpath index, anchor index) inside a path.
pub type AnchorRef = (usize, usize);
@@ -125,6 +125,25 @@ impl Selection {
pub fn partial(&self, id: NodeId) -> Option<&BTreeSet<AnchorRef>> {
self.anchors.get(&id)
}
/// The objects selected whole: those with no direct-selected anchors.
pub fn whole(&self) -> Vec<NodeId> {
self.objects.iter().copied().filter(|id| !self.anchors.contains_key(id)).collect()
}
/// With direct-selected anchors, what a transform of them measures from: the box around those
/// anchors and the objects selected whole (`visual`: with their strokes). `None` without any.
pub fn anchor_bounds(&self, doc: &Document, visual: bool) -> Option<vectorcraft_geom::Rect> {
if self.anchors.is_empty() {
return None;
}
let mut acc = doc.bounds_of(&self.whole(), visual);
for (id, set) in &self.anchors {
let Some(NodeKind::Path { path, .. }) = doc.node(*id).map(|n| &n.kind) else { continue };
for a in set.iter().filter_map(|&(si, ai)| path.subpaths.get(si)?.anchors.get(ai)) {
acc = vectorcraft_geom::union_opt(acc, Some(vectorcraft_geom::Rect::from_points(a.p, a.p)));
}
}
acc
}
/// Which direction handles belong to selected segments; both for an anchor selection.
pub fn handle_sides(&self, id: NodeId, si: usize, ai: usize, sp: &vectorcraft_geom::SubPath) -> (bool, bool) {
let Some(segments) = self.segments.get(&id) else { return (true, true) };
+5
View File
@@ -19,6 +19,11 @@ impl<T> Shared<T> {
pub fn ptr_eq(a: &Self, b: &Self) -> bool {
Arc::ptr_eq(&a.0, &b.0)
}
/// A weak reference to the value: it keeps the allocation, so the value's address names it
/// while the reference is kept, but not the value.
pub fn downgrade(this: &Self) -> std::sync::Weak<T> {
Arc::downgrade(&this.0)
}
}
impl<T> std::ops::Deref for Shared<T> {
+33 -9
View File
@@ -109,10 +109,11 @@ impl Document {
}
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
/// strokes and text runs — in the art, symbol definitions, pattern tiles, graphic styles and the
/// swatches themselves (tint swatches, gradient swatches' stops). `f(colour, link, tint)` may
/// change them and returns true when it did ([`Paint::map_links`]). Only nodes holding a changed
/// paint (and the paths to them) are copied. Returns the number of changed paints.
/// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern
/// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops).
/// `f(colour, link, tint)` may change them and returns true when it did
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
/// copied. Returns the number of changed paints.
pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize {
let mut n = map_trees(&mut self.layers, f);
for s in &mut self.symbols {
@@ -152,11 +153,12 @@ impl Document {
}
impl Document {
/// Visit every paint held by the art, symbol definitions, pattern tiles and graphic styles
/// (fills, strokes and text runs), and the colour of every gradient-mesh point as a solid paint.
/// Visit every paint held by the art (opacity masks included), symbol definitions, pattern
/// tiles and graphic styles (fills, strokes and text runs), and the colour of every
/// gradient-mesh point as a solid paint.
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
let mut tree = |n: &Node| {
n.walk(&mut |m| {
walk_with_masks(n, &mut |m| {
node_paints(m).for_each(&mut *f);
if let NodeKind::Mesh(mesh) = &m.kind {
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
@@ -224,7 +226,7 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
}
/// The paints a node holds itself (not its children): appearance items, then text runs.
fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
let runs = match &n.kind {
NodeKind::Text(t) => t.runs.as_slice(),
_ => &[],
@@ -233,7 +235,7 @@ fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
}
/// Mutable [`node_paints`], in the same order.
fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
let runs = match &mut n.kind {
NodeKind::Text(t) => t.runs.as_mut_slice(),
_ => &mut [],
@@ -280,9 +282,31 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
}
}
}
// Opacity mask art lives outside the tree, but its colours are the object's too (#1735).
if let Some(mask) = n.extras().mask.as_deref()
&& let Some((new, k)) = map_node(&mask.art, f)
{
let m = out.get_or_insert_with(|| n.clone());
if let Some(slot) = m.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
slot.art = Arc::new(new);
count += k;
}
}
out.map(|m| (m, count))
}
/// Visit `n` and its subtree, the art of opacity masks included (mask art after the masked
/// object, before its children).
fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
f(n);
if let Some(mask) = n.extras().mask.as_deref() {
walk_with_masks(&mask.art, f);
}
for c in n.children().into_iter().flatten() {
walk_with_masks(c, f);
}
}
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
+565
View File
@@ -0,0 +1,565 @@
//! Symbol instances measured by their symbol's art: [`SymbolBoxes`] looks each symbol's art up in
//! the document and measures it once, and an instance's bounds are its art's box placed by the
//! instance's transform ([`Node::geometric_bounds_with`]).
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use vectorcraft_geom::{Affine, Rect};
use crate::{AppearanceItem, Document, Node, NodeKind, SYMBOL_HALF};
/// The box a symbol instance is measured by when its symbol's art has no box
/// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin
/// that symbols made in the app keep their art in.
pub(crate) const SYMBOL_SQUARE: Rect = Rect::new(-SYMBOL_HALF, -SYMBOL_HALF, SYMBOL_HALF, SYMBOL_HALF);
/// Instances of a symbol whose art holds symbols nested this many deep are measured by
/// [`SYMBOL_SQUARE`] instead of their art.
const MAX_DEPTH: usize = 32;
/// Most effect reaches one set of boxes measures for transforms that place the art of symbols
/// holding instances: past it, an estimate stands in ([`SymbolBoxes::effects_reach`]).
const MAX_REACHES: usize = 4096;
/// A symbol's art measured in symbol space.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ArtBox {
/// The art's geometric bounds.
pub(crate) geometric: Rect,
/// The art's visual bounds (its strokes included), never smaller than `geometric`.
pub(crate) visual: Rect,
/// The farthest any stroke in the art can paint from its path ([`crate::Appearance::outset`]).
pub(crate) reach: f64,
/// Whether the art, or the art of a symbol an instance in it shows, has a visible effect.
pub(crate) effects: bool,
/// The widest stroke of the objects with a visible brushed stroke in the art, or in the art
/// of a symbol an instance in it shows: `None` when no object there has one.
pub(crate) brush: Option<f64>,
}
/// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`],
/// [`Node::visual_bounds_with`] and [`Node::reach_bounds_with`] measure symbol instances by. Each
/// symbol's art is measured the first time an instance of it is measured; make a new one after the
/// symbols change.
pub struct SymbolBoxes<'a> {
doc: &'a Document,
/// The art boxes measured so far, by symbol name (`None` for a symbol whose instances are
/// measured by [`SYMBOL_SQUARE`]).
measured: RefCell<HashMap<String, Option<ArtBox>>>,
/// How deep symbols nest in the art of each symbol checked so far, by symbol name
/// ([`Self::depth`]).
depths: RefCell<HashMap<String, Option<usize>>>,
/// Set while symbol art or a click's box is measured, and always in boxes made by
/// [`Self::quick`]: instances are then measured by their art boxes alone
/// ([`Self::placed_visual`] returns `None`).
quick: Cell<bool>,
/// Set in boxes made by [`Self::with_brush_art`], and cleared while symbol art is measured
/// ([`Self::brush_pad`]).
brush_art: Cell<bool>,
/// Cleared in boxes made by [`Self::without_art_strokes`], and set while symbol art is
/// measured ([`Self::art_strokes`]).
art_strokes: Cell<bool>,
/// The effect reaches measured so far, by symbol name and the bits of the linear part of the
/// transform that places the art ([`Self::effects_reach`]).
reaches: RefCell<HashMap<(String, [u64; 4]), f64>>,
/// How many of them are of symbols whose art holds instances.
holders: Cell<usize>,
/// The farthest effect reach measured so far for each symbol.
farthest: RefCell<HashMap<String, f64>>,
/// The effect reach of each symbol's art with no transform, measured past [`MAX_REACHES`]
/// ([`Self::unplaced_reach`]).
unplaced: RefCell<HashMap<String, f64>>,
}
impl Document {
/// [`SymbolBoxes`] for this document's symbols, none measured yet.
pub fn symbol_boxes(&self) -> SymbolBoxes<'_> {
SymbolBoxes {
doc: self,
measured: RefCell::default(),
depths: RefCell::default(),
quick: Cell::new(false),
brush_art: Cell::new(false),
art_strokes: Cell::new(true),
reaches: RefCell::default(),
holders: Cell::new(0),
farthest: RefCell::default(),
unplaced: RefCell::default(),
}
}
}
impl<'a> SymbolBoxes<'a> {
/// The document whose symbols these are.
pub fn document(&self) -> &'a Document {
self.doc
}
/// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its
/// art box alone. An instance that scales or turns stroked art then measures a little larger
/// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it.
pub fn quick(self) -> Self {
self.quick.set(true);
self
}
/// Makes these boxes grow the visual box of each symbol instance whose art holds brushed
/// strokes by as far as their brush art can paint past it ([`Self::brush_pad`]). The visual
/// boxes of the groups, layers and live objects that hold such an instance grow with it.
pub fn with_brush_art(self) -> Self {
self.brush_art.set(true);
self
}
/// Makes these boxes measure the visual box of each symbol instance without the strokes in
/// its art: the art's geometric box placed by the instance's transform, grown by the
/// instance's own strokes. The strokes in the art keep their weight when the instance scales.
/// The visual boxes of the groups, layers and live objects that hold an instance do not include
/// those strokes either.
pub fn without_art_strokes(self) -> Self {
self.art_strokes.set(false);
self
}
/// Whether instances are measured with the strokes in their art: false in boxes made by
/// [`Self::without_art_strokes`], except while symbol art is measured.
pub(crate) fn art_strokes(&self) -> bool {
self.art_strokes.get()
}
/// The art box of the symbol named `name`, with the instances in the art measured by their own
/// art boxes. Returns `None` when [`Self::depth`] is `None` and when the art has no finite
/// bounds: instances of the symbol are then measured by [`SYMBOL_SQUARE`].
pub(crate) fn art(&self, name: &str) -> Option<ArtBox> {
if let Some(b) = self.measured.borrow().get(name) {
return *b;
}
let b = self.measure_art(name, |art| self.in_art(|| art_box(art, self))).flatten();
self.measured.borrow_mut().insert(name.to_string(), b);
b
}
/// Runs `measure` on the art of the symbol named `name`. Returns `None` when [`Self::depth`]
/// is `None`, without running it.
pub fn measure_art<R>(&self, name: &str, measure: impl FnOnce(&Arc<Node>) -> R) -> Option<R> {
self.depth(name)?;
let sym = self.doc.symbols.iter().find(|s| s.name == name)?;
Some(measure(&sym.art))
}
/// The geometric box of the art of the symbol named `name` in symbol space, with the instances
/// in the art measured by their own art boxes. `None` when [`Self::art`] is `None`: instances of
/// the symbol are then measured by [`SYMBOL_SQUARE`].
pub fn art_bounds(&self, name: &str) -> Option<Rect> {
self.art(name).map(|a| a.geometric)
}
/// Whether the art of the symbol named `name`, or of a symbol an instance in it shows, has a
/// visible effect (raster or geometry, the object's own or a fill's or stroke's). False when
/// [`Self::art`] is `None`.
pub fn has_effects(&self, name: &str) -> bool {
self.art(name).is_some_and(|a| a.effects)
}
/// How far the effects in the art of the symbol named `name` paint past that art when a
/// transform with the linear part `linear` places it, as `measure` measures it for a linear
/// part. `measure` runs once for each symbol and linear part. For symbols whose art holds
/// instances, it runs [`MAX_REACHES`] times at most for these boxes. After that, a linear part
/// not measured yet takes the reach with no transform, times √2 and the larger of 1 and the
/// linear part's Frobenius norm, or the farthest reach measured for the symbol when that is
/// farther; `measure` runs once more for each of those symbols whose reach with no transform
/// was not measured yet. The estimate holds for effects that paint a fixed distance past the
/// art, or a distance in proportion to its size.
pub fn effects_reach(&self, name: &str, linear: [f64; 4], measure: &dyn Fn([f64; 4]) -> f64) -> f64 {
let key = (name.to_string(), linear.map(f64::to_bits));
if let Some(r) = self.reaches.borrow().get(&key) {
return *r;
}
let holder = self.depth(name) != Some(0);
if holder && self.holders.get() >= MAX_REACHES {
let norm = linear.iter().map(|v| v * v).sum::<f64>().sqrt();
let estimate = self.unplaced_reach(name, measure) * std::f64::consts::SQRT_2 * norm.max(1.0);
return self.farthest.borrow().get(name).map_or(estimate, |f| f.max(estimate));
}
if holder {
self.holders.set(self.holders.get() + 1);
}
let r = measure(linear);
self.reaches.borrow_mut().insert(key, r);
let mut farthest = self.farthest.borrow_mut();
let f = farthest.entry(name.to_string()).or_insert(r);
*f = f.max(r);
r
}
/// [`Self::effects_reach`] with no transform, measured once for each symbol, also past
/// [`MAX_REACHES`].
fn unplaced_reach(&self, name: &str, measure: &dyn Fn([f64; 4]) -> f64) -> f64 {
const IDENTITY: [f64; 4] = [1.0, 0.0, 0.0, 1.0];
if let Some(r) = self.reaches.borrow().get(&(name.to_string(), IDENTITY.map(f64::to_bits))) {
return *r;
}
if let Some(r) = self.unplaced.borrow().get(name) {
return *r;
}
let r = measure(IDENTITY);
self.unplaced.borrow_mut().insert(name.to_string(), r);
r
}
/// The widest stroke of the objects with a visible brushed stroke in the art of the symbol
/// named `name`, or in the art of a symbol an instance in it shows. `None` when no object there
/// has one, and when [`Self::art`] is `None`.
pub fn brush_width(&self, name: &str) -> Option<f64> {
self.art(name)?.brush
}
/// How far the visual box of an instance of the symbol named `name` grows in boxes made by
/// [`Self::with_brush_art`]: `16 × max(w, 1) + 8` for the widest stroke `w` that
/// [`Self::brush_width`] finds, as far as the renderer grows the cull bounds of a brushed path
/// (`brush_fx::cull_bounds` in vectorcraft-render). 0 in other boxes, while symbol art is
/// measured, and when the art has no brushed stroke.
pub(crate) fn brush_pad(&self, name: &str) -> f64 {
if !self.brush_art.get() {
return 0.0;
}
self.brush_width(name).map_or(0.0, |w| 16.0 * w.max(1.0) + 8.0)
}
/// How deep symbols nest in the art of the symbol named `name`: 0 when the art holds no
/// instance of a symbol in the document, 1 when the symbols its instances show hold none, and
/// so on. Returns `None` when the document has no such symbol, when the symbol or a symbol
/// nested in its art holds an instance of itself at any depth, and when symbols nest
/// [`MAX_DEPTH`] deep: instances of the symbol are then measured by [`SYMBOL_SQUARE`]. The
/// answer depends only on the document's symbols.
fn depth(&self, name: &str) -> Option<usize> {
if let Some(d) = self.depths.borrow().get(name) {
return *d;
}
let mut learned = HashMap::new();
let d = self.find_depth(name, &mut learned);
let mut depths = self.depths.borrow_mut();
depths.extend(learned);
depths.insert(name.to_string(), d);
d
}
/// Finds [`Self::depth`] for the symbol named `name` by walking the symbols its art's instances
/// show, depth first, without recursing. `learned` collects what the walk finds out about the
/// other symbols it reaches: the depth of each one it walks to the end, and `None` for those on
/// the way down to a symbol that holds an instance of itself or nests too deep.
fn find_depth(&self, name: &str, learned: &mut HashMap<String, Option<usize>>) -> Option<usize> {
let art = |n: &str| self.doc.symbols.iter().find(|s| s.name == n).map(|s| &s.art);
// The symbols on the way down from `name`: each with the symbols its art shows that are
// still to be walked, and the deepest nesting found under it so far.
let mut path: Vec<(String, Vec<String>, usize)> = vec![(name.to_string(), shown(self.doc, art(name)?), 0)];
loop {
let (_, todo, _) = path.last_mut()?;
let Some(next) = todo.pop() else {
// Everything under it is walked.
let (done, _, d) = path.pop()?;
if d >= MAX_DEPTH {
learned.insert(done, None);
return give_up(&path, learned);
}
learned.insert(done, Some(d));
let Some((_, _, up)) = path.last_mut() else { return Some(d) };
*up = (*up).max(d + 1);
continue;
};
let known = learned.get(&next).copied().or_else(|| self.depths.borrow().get(&next).copied());
match known {
Some(Some(d)) => {
if let Some((_, _, deepest)) = path.last_mut() {
*deepest = (*deepest).max(d + 1);
}
}
// It holds an instance of itself or nests too deep, and so does every symbol on
// the way down to it.
Some(None) => return give_up(&path, learned),
// A symbol that holds an instance of itself.
None if path.iter().any(|(n, ..)| *n == next) => return give_up(&path, learned),
// Symbols nested MAX_DEPTH deep under `name`.
None if path.len() >= MAX_DEPTH => return None,
None => {
if let Some(a) = art(&next) {
let todo = shown(self.doc, a);
path.push((next, todo, 0));
}
}
}
}
}
/// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it,
/// its strokes at their own weight, the instances in it measured by their art boxes. `None`
/// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], and while symbol art or a
/// click's box is measured.
pub(crate) fn placed_visual(&self, name: &str, xf: Affine) -> Option<Rect> {
if self.quick.get() {
return None;
}
self.measure_art(name, |art| {
let mut art = (**art).clone();
art.transform(xf, false);
self.in_art(|| art.visual(Some(self)))
})
.flatten()
}
/// Runs `f` with instances measured by their art boxes alone.
pub(crate) fn quickly<R>(&self, f: impl FnOnce() -> R) -> R {
let was = self.quick.replace(true);
let r = f();
self.quick.set(was);
r
}
/// Runs `f` as symbol art is measured: instances measured by their art boxes alone, with the
/// strokes in their art, and without [`Self::brush_pad`], which the instance that shows the
/// art adds once.
fn in_art<R>(&self, f: impl FnOnce() -> R) -> R {
let pad = self.brush_art.replace(false);
let strokes = self.art_strokes.replace(true);
let r = self.quickly(f);
self.brush_art.set(pad);
self.art_strokes.set(strokes);
r
}
}
/// Records every symbol on `path` in `learned` as one whose instances are measured by
/// [`SYMBOL_SQUARE`]. Returns `None`.
fn give_up(path: &[(String, Vec<String>, usize)], learned: &mut HashMap<String, Option<usize>>) -> Option<usize> {
for (n, ..) in path {
learned.insert(n.clone(), None);
}
None
}
/// The names of the symbols of `doc` that the instances in `art` show, each once.
fn shown(doc: &Document, art: &Node) -> Vec<String> {
let mut seen = HashSet::new();
let mut names = vec![];
art.walk(&mut |n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
&& seen.insert(symbol.as_str())
&& doc.symbols.iter().any(|s| s.name == *symbol)
{
names.push(symbol.clone());
}
});
names
}
/// Measures symbol art `art`, with the instances in it measured by `symbols`.
fn art_box(art: &Arc<Node>, symbols: &SymbolBoxes) -> Option<ArtBox> {
let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite());
let geometric = art.geometric(Some(symbols)).filter(finite)?;
let visual = art.visual(Some(symbols)).filter(finite).map_or(geometric, |v| v.union(geometric));
let reach = crate::live::max_outset(std::slice::from_ref(art), Some(symbols));
let mut effects = false;
let mut brush: Option<f64> = None;
art.walk(&mut |n| {
let mut own = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects().iter()));
effects |= own.any(|e| e.visible);
let strokes = || n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None });
if strokes().any(|s| s.visible && s.brush.is_some()) {
brush = Some(strokes().fold(brush.unwrap_or(0.0), |w, s| w.max(s.width)));
}
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
effects |= symbols.has_effects(symbol);
if let Some(w) = symbols.brush_width(symbol) {
brush = Some(brush.map_or(w, |b| b.max(w)));
}
}
});
Some(ArtBox { geometric, visual, reach, effects, brush })
}
#[cfg(test)]
mod tests {
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::shapes;
use super::*;
use crate::{Appearance, Symbol};
/// A rectangle filled black, with a `stroke` pt black stroke (0: none).
fn rect(d: &mut Document, r: Rect, stroke: f64) -> Node {
let paint = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None };
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), paint, stroke))
}
fn instance(d: &mut Document, symbol: &str, xf: Affine) -> Node {
Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf })
}
/// Symbol `name` holding `art` in a group.
fn symbol(d: &mut Document, name: &str, art: Vec<Node>) {
let group = Node::group(d.alloc_id(), art.into_iter().map(Arc::new).collect());
d.symbols.push(Symbol { name: name.into(), art: Arc::new(group) });
}
#[test]
fn an_instance_measures_as_its_art_placed_by_its_transform() {
let mut d = Document::new(200.0, 200.0);
let bar = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 10.0), 2.0);
symbol(&mut d, "Bar", vec![bar]);
let moved = instance(&mut d, "Bar", Affine::translate((20.0, 30.0)));
let scaled = instance(&mut d, "Bar", Affine::scale(3.0));
let s = d.symbol_boxes();
assert_eq!(moved.geometric_bounds_with(&s), Some(Rect::new(20.0, 30.0, 40.0, 40.0)));
// Without the document: the 20 × 20 square around the origin, moved.
assert_eq!(moved.geometric_bounds(), Some(Rect::new(10.0, 20.0, 30.0, 40.0)));
// The 2 pt stroke paints 1 pt around the bar.
assert_eq!(moved.visual_bounds_with(&s), Some(Rect::new(19.0, 29.0, 41.0, 41.0)));
assert_eq!(moved.reach_bounds_with(&s), moved.visual_bounds_with(&s));
// Scaled 3 times: the geometry scales, the stroke keeps its weight.
assert_eq!(scaled.geometric_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0)));
assert_eq!(scaled.visual_bounds_with(&s), Some(Rect::new(-1.0, -1.0, 61.0, 31.0)));
// A click's box: the geometric box grown by the farthest the stroke can reach (a mitered
// corner up to its miter limit).
let reach = d.symbols[0].art.children().unwrap()[0].appearance.outset();
assert_eq!(scaled.reach_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0).inflate(reach, reach)));
}
#[test]
fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() {
let mut d = Document::new(200.0, 200.0);
let missing = instance(&mut d, "Nowhere", Affine::translate((50.0, 50.0)));
assert_eq!(missing.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(40.0, 40.0, 60.0, 60.0)));
// A symbol whose art holds an instance of itself 100 pt to the right, and a symbol whose
// art holds an instance of that one.
let square = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0);
let inner = instance(&mut d, "Loop", Affine::translate((100.0, 0.0)));
symbol(&mut d, "Loop", vec![square.clone(), inner]);
let looped = instance(&mut d, "Loop", Affine::IDENTITY);
symbol(&mut d, "Holder", vec![square, looped]);
let outer = instance(&mut d, "Loop", Affine::IDENTITY);
let doubled = instance(&mut d, "Loop", Affine::scale(2.0));
let holder = instance(&mut d, "Holder", Affine::translate((50.0, 50.0)));
let s = d.symbol_boxes();
assert_eq!(outer.geometric_bounds_with(&s), Some(Rect::new(-10.0, -10.0, 10.0, 10.0)));
assert_eq!(outer.visual_bounds_with(&s), Some(Rect::new(-10.0, -10.0, 10.0, 10.0)));
assert_eq!(doubled.visual_bounds_with(&s), Some(Rect::new(-20.0, -20.0, 20.0, 20.0)));
assert_eq!(holder.geometric_bounds_with(&s), Some(Rect::new(40.0, 40.0, 60.0, 60.0)));
}
#[test]
fn nested_instances_measure_by_their_own_symbols_art() {
let mut d = Document::new(200.0, 200.0);
let dot = rect(&mut d, Rect::new(0.0, 0.0, 4.0, 4.0), 0.0);
symbol(&mut d, "Dot", vec![dot]);
let right = instance(&mut d, "Dot", Affine::translate((50.0, 0.0)));
let below = instance(&mut d, "Dot", Affine::translate((0.0, 50.0)));
symbol(&mut d, "Pair", vec![right, below]);
let pair = instance(&mut d, "Pair", Affine::translate((100.0, 100.0)));
assert_eq!(pair.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(100.0, 100.0, 154.0, 154.0)));
}
#[test]
fn symbols_nested_32_deep_measure_as_the_square_whatever_was_measured_first() {
// A chain of 40 symbols, each holding a 1 × 1 square and an instance of the next one 1 pt to
// the right: symbols nest 39 deep in Link 0's art and 31 deep in Link 8's.
let mut d = Document::new(200.0, 200.0);
for i in 0..40 {
let unit = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0);
let next = instance(&mut d, &format!("Link {}", i + 1), Affine::translate((1.0, 0.0)));
symbol(&mut d, &format!("Link {i}"), if i < 39 { vec![unit, next] } else { vec![unit] });
}
let link0 = instance(&mut d, "Link 0", Affine::IDENTITY);
let link7 = instance(&mut d, "Link 7", Affine::IDENTITY);
let link8 = instance(&mut d, "Link 8", Affine::IDENTITY);
let (square, chain) = (Some(Rect::new(-10.0, -10.0, 10.0, 10.0)), Some(Rect::new(0.0, 0.0, 32.0, 1.0)));
let s = d.symbol_boxes();
assert_eq!(link0.geometric_bounds_with(&s), square);
assert_eq!(link8.geometric_bounds_with(&s), chain);
assert_eq!(link7.geometric_bounds_with(&s), square);
let s = d.symbol_boxes();
assert_eq!(link8.geometric_bounds_with(&s), chain);
assert_eq!(link0.geometric_bounds_with(&s), square);
}
#[test]
fn brushed_strokes_are_found_in_the_art_and_in_nested_symbols_art() {
let mut d = Document::new(200.0, 200.0);
let mut line = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 3.0);
line.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
symbol(&mut d, "Brushed", vec![line]);
let plain = rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 5.0);
let inner = instance(&mut d, "Brushed", Affine::IDENTITY);
symbol(&mut d, "Holder", vec![plain.clone(), inner]);
symbol(&mut d, "Plain", vec![plain]);
let s = d.symbol_boxes();
assert_eq!(s.brush_width("Brushed"), Some(3.0));
assert_eq!(s.brush_width("Holder"), Some(3.0), "the 5 pt stroke has no brush");
assert_eq!(s.brush_width("Plain"), None);
}
#[test]
fn boxes_with_brush_art_grow_instances_of_brushed_art_once() {
let mut d = Document::new(200.0, 200.0);
// A line from (0, 40) to (0, 60) with a 1 pt brushed stroke, and a symbol whose art holds an
// instance of it.
let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let line = Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke);
symbol(&mut d, "Nib", vec![line]);
let inner = instance(&mut d, "Nib", Affine::IDENTITY);
symbol(&mut d, "Holder", vec![inner]);
let nib = instance(&mut d, "Nib", Affine::translate((-5.0, 0.0)));
let holder = instance(&mut d, "Holder", Affine::translate((-5.0, 0.0)));
let group = Node::group(d.alloc_id(), vec![Arc::new(nib.clone())]);
let repeat = Node::new(d.alloc_id(), NodeKind::Repeat(crate::RepeatSpec::mirror(vec![Arc::new(nib.clone())], 90.0, 10.0)));
let (plain, brushed) = (d.symbol_boxes(), d.symbol_boxes().with_brush_art());
// 16 × 1 + 8 for the 1 pt stroke.
for n in [&nib, &holder, &group, &repeat] {
let b = n.visual_bounds_with(&plain).unwrap();
assert_eq!(n.visual_bounds_with(&brushed), Some(b.inflate(24.0, 24.0)), "{}", n.kind_label());
}
assert_eq!(nib.geometric_bounds_with(&brushed), nib.geometric_bounds_with(&plain));
}
#[test]
fn effect_reaches_are_measured_once_for_each_linear_part_and_up_to_a_limit() {
// `Leaf` holds a square; `Holder` and `Other` each hold an instance of `Leaf`.
let mut d = Document::new(10.0, 10.0);
let square = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0);
symbol(&mut d, "Leaf", vec![square]);
for name in ["Holder", "Other"] {
let leaf = instance(&mut d, "Leaf", Affine::IDENTITY);
symbol(&mut d, name, vec![leaf]);
}
let s = d.symbol_boxes();
let runs = &Cell::new(0);
// A measure that finds `reach` for every linear part.
let measure = |reach: f64| {
move |_: [f64; 4]| {
runs.set(runs.get() + 1);
reach
}
};
let unscaled = [1.0, 0.0, 0.0, 1.0];
assert_eq!(s.effects_reach("Holder", unscaled, &measure(3.0)), 3.0);
assert_eq!(s.effects_reach("Holder", unscaled, &measure(5.0)), 3.0, "measured once");
assert_eq!(runs.get(), 1);
for i in 1..MAX_REACHES {
s.effects_reach("Holder", [1.0, 0.0, 0.0, 1.0 + i as f64], &measure(1.0));
}
assert_eq!(runs.get(), MAX_REACHES);
// Past the limit: the reach with no transform, times √2 and the larger of 1 and the
// Frobenius norm (√8 for a scale of 2, √2 with no transform, √0.5 for a scale of 0.5). That
// reach is measured once more for a symbol not measured before.
let near = |a: f64, b: f64| (a - b).abs() < 1e-9;
assert!(near(s.effects_reach("Holder", [2.0, 0.0, 0.0, 2.0], &measure(9.0)), 12.0));
assert!(near(s.effects_reach("Other", unscaled, &measure(9.0)), 18.0));
assert!(near(s.effects_reach("Other", [0.5, 0.0, 0.0, 0.5], &measure(7.0)), 9.0 * std::f64::consts::SQRT_2));
assert_eq!(runs.get(), MAX_REACHES + 1);
// A symbol whose art holds no instance is measured past the limit too.
assert_eq!(s.effects_reach("Leaf", [2.0, 0.0, 0.0, 2.0], &measure(7.0)), 7.0);
assert_eq!(runs.get(), MAX_REACHES + 2);
}
}
+129 -1
View File
@@ -1,5 +1,7 @@
//! The batched lookups ([`Document::index_paths`], `node_at`, `node_mut_at`, `ancestry_at`) agree
//! with the one-id ones on nested trees with missing and duplicated ids.
//! with the one-id ones on nested trees with missing and duplicated ids, and the edits of many nodes
//! ([`Document::remove_many`], `wrap`, `insert_beside`) leave the tree that `remove`, `insert` and
//! `move_node` leave one node at a time.
use super::*;
use vectorcraft_geom::shapes;
@@ -154,3 +156,129 @@ fn cached_lookups_follow_any_edit_of_the_tree() {
agree(&snapshot, &ids, "the earlier clone");
}
}
/// Nodes of `d` picked and ordered by `seed`. None lies inside another, and each of them, and each
/// node it is in, appears in the tree once.
fn picks(d: &Document, seed: u64) -> Vec<NodeId> {
let mut count = std::collections::HashMap::<NodeId, usize>::new();
d.walk(|n| *count.entry(n.id).or_default() += 1);
let once = |id: &NodeId| count.get(id) == Some(&1);
let mut found: Vec<(Vec<usize>, NodeId)> = count
.keys()
.filter(|id| !(id.0 + seed).is_multiple_of(3))
.filter_map(|id| d.index_path(*id).map(|p| (p, *id)))
.filter(|(p, _)| d.ancestry_at(p).is_some_and(|a| a.iter().all(once)))
.collect();
found.sort();
let mut kept: Vec<(Vec<usize>, NodeId)> = vec![];
for (p, id) in found {
if !kept.iter().any(|(q, _)| p.starts_with(q)) {
kept.push((p, id));
}
}
let mut out: Vec<NodeId> = kept.into_iter().map(|(_, id)| id).collect();
out.sort_by_key(|id| id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ seed);
out
}
/// `a` and `b` hold the same tree, and their lookups agree with each other and with a walk.
fn same(a: &Document, b: &Document, ids: &[NodeId], what: &str) {
assert_eq!(serde_json::to_string(a).ok(), serde_json::to_string(b).ok(), "{what}");
for id in ids {
assert_eq!(a.index_path(*id), b.index_path(*id), "{what}: {id:?}");
}
agree(b, ids, what);
}
/// A 1 pt square with a new id.
fn square(d: &mut Document) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())
}
#[test]
fn remove_many_matches_removing_one_at_a_time() {
for seed in 1..40 {
let (d, ids) = tree(seed);
let picked = picks(&d, seed);
let (mut a, mut b) = (d.clone(), d.clone());
let one: Vec<Arc<Node>> = picked.iter().map(|id| a.remove(*id).unwrap()).collect();
let many = b.remove_many(&picked).unwrap();
assert_eq!(many.len(), one.len(), "seed {seed}");
assert!(one.iter().zip(&many).all(|(x, y)| Arc::ptr_eq(x, y)), "seed {seed}: the same nodes, in the order of the list");
same(&a, &b, &ids, &format!("seed {seed}"));
}
}
#[test]
fn insert_beside_matches_inserting_one_at_a_time() {
for seed in 1..40 {
for replace in [false, true] {
let (mut d, mut ids) = tree(seed);
let picked = picks(&d, seed);
// None to three new nodes for each anchor.
let items: Vec<(NodeId, Vec<Node>)> =
picked.iter().enumerate().map(|(k, id)| (*id, (0..k % 4).map(|_| square(&mut d)).collect())).collect();
ids.extend(items.iter().flat_map(|(_, new)| new.iter().map(|n| n.id)));
let (mut a, mut b) = (d.clone(), d.clone());
for (id, new) in items.clone() {
let (parent, at, _) = a.position(id).unwrap();
let at = if replace {
a.remove(id).unwrap();
at
} else {
at + 1
};
for (k, n) in new.into_iter().enumerate() {
a.insert(parent, at + k, n).unwrap();
}
}
b.insert_beside(items, replace).unwrap();
same(&a, &b, &ids, &format!("seed {seed}, replace {replace}"));
}
}
}
#[test]
fn wrap_matches_inserting_a_group_and_moving_the_nodes_into_it_one_at_a_time() {
for seed in 1..40 {
let (mut d, mut ids) = tree(seed);
let picked = picks(&d, seed);
let Some(&last) = picked.last() else { continue };
let group = d.alloc_id();
ids.push(group);
let (mut a, mut b) = (d.clone(), d.clone());
let (parent, at, _) = a.position(last).unwrap();
a.insert(parent, at + 1, Node::group(group, vec![])).unwrap();
for id in &picked {
a.move_node(*id, Some(group), usize::MAX).unwrap();
}
assert_eq!(b.wrap(&picked, |nodes| Node::group(group, nodes)), Ok(group), "seed {seed}");
same(&a, &b, &ids, &format!("seed {seed}"));
}
}
/// An id not in the tree, listed twice, or inside another listed node fails with `NoNode`, and the
/// document stays as it was.
#[test]
fn edits_of_many_refuse_missing_repeated_and_nested_ids() {
let mut d = Document::new(612.0, 792.0);
let layer = d.layers[0].id;
let (x, y, z) = (square(&mut d), square(&mut d), square(&mut d));
let (inner, other) = (y.id, z.id);
let group = d.alloc_id();
d.insert(Some(layer), 0, Node::group(group, vec![Arc::new(x), Arc::new(y)])).unwrap();
d.insert(Some(layer), 1, z).unwrap();
let saved = serde_json::to_string(&d).ok();
let missing = NodeId(999_999);
for (list, bad) in [(vec![other, missing], missing), (vec![other, other], other), (vec![group, inner], inner), (vec![inner, group], inner)] {
let what = format!("{list:?}");
let mut b = d.clone();
assert_eq!(b.remove_many(&list).err(), Some(DocError::NoNode(bad)), "{what}");
assert_eq!(b.wrap(&list, |nodes| Node::group(NodeId(1_000_000), nodes)).err(), Some(DocError::NoNode(bad)), "{what}");
for replace in [false, true] {
let items = list.iter().map(|id| (*id, vec![])).collect();
assert_eq!(b.insert_beside(items, replace).err(), Some(DocError::NoNode(bad)), "{what}");
}
assert_eq!(serde_json::to_string(&b).ok(), saved, "{what}: the document stays as it was");
}
}
+117
View File
@@ -0,0 +1,117 @@
//! Object names ↔ XML IDs (Preferences › Units › Identify Objects By).
//!
//! Illustrator's public documentation: with XML ID selected, the Layers panel shows and edits
//! names that conform to XML naming (letter, underscore or colon first; no spaces). Underscores
//! stand for spaces and `_xHH_` / `_xHHHH_` escape other characters, as in `.ai` `AI10_ArtUID`
//! values ([edit dynamic objects](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/edit-dynamic-objects.html)).
/// An object name from its XML id: `_` is a space and `_xHH_` / `_xHHHH_` a character; ids made
/// unique end with `_` and 20 or more digits (stripped).
pub fn to_object_name(id: &str) -> String {
let id = strip_unique(id);
let mut out = String::with_capacity(id.len());
let mut rest = id;
while let Some(c) = rest.chars().next() {
if c == '_' {
if let Some((ch, n)) = escaped(rest) {
out.push(ch);
rest = rest.get(n..).unwrap_or_default();
continue;
}
out.push(' ');
} else {
out.push(c);
}
rest = rest.get(c.len_utf8()..).unwrap_or_default();
}
out
}
/// The XML id for an object name (the inverse of [`to_object_name`]).
pub fn from_object_name(name: &str) -> String {
let mut out = String::with_capacity(name.len().saturating_mul(2));
for (i, c) in name.chars().enumerate() {
if c == ' ' {
out.push('_');
} else if c == '_' {
out.push_str("_x5F_");
} else if i == 0 && c.is_ascii_digit() {
out.push_str(&format!("_x{:X}_", u32::from(c)));
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
out.push(c);
} else {
out.push_str(&format!("_x{:X}_", u32::from(c)));
}
}
if out.is_empty() || !starts_name(&out) {
out.insert(0, '_');
}
out
}
/// Whether `id` is a usable XML ID for Identify Objects By › XML ID: non-empty, no whitespace,
/// starts with a letter, `_` or `:`, and otherwise letters, digits, `.`, `-`, `_` or `:`.
pub fn is_valid(id: &str) -> bool {
let id = id.trim();
if id.is_empty() || id.chars().any(char::is_whitespace) {
return false;
}
let mut chars = id.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_alphabetic() || first == '_' || first == ':') {
return false;
}
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
}
fn starts_name(id: &str) -> bool {
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
let end = body.find('_')?;
let hex = body.get(..end)?;
if hex.is_empty() || hex.len() > 6 {
return None;
}
let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?;
Some((c, end + 3))
}
/// `id` without the `_000…_` suffix that makes it unique.
fn strip_unique(id: &str) -> &str {
let Some(body) = id.strip_suffix('_') else {
return id;
};
let Some(at) = body.rfind('_') else {
return id;
};
let digits = body.get(at + 1..).unwrap_or_default();
if digits.len() >= 20 && digits.bytes().all(|b| b.is_ascii_digit()) { body.get(..at).unwrap_or(id) } else { id }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_ai_style_ids() {
for (name, id) in [
("Layer 1", "Layer_1"),
("My Rect #2", "My_Rect__x23_2"),
("1abc (paren)", "_x31_abc__x28_paren_x29_"),
("Ünï 日本", "Ünï_日本"),
("a_b", "a_x5F_b"),
] {
assert_eq!(from_object_name(name), id, "encode {name}");
assert_eq!(to_object_name(id), name, "decode {id}");
}
assert_eq!(to_object_name("S_00000092429200007574234970000005974767392385616558_"), "S");
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
}
}
+4 -3
View File
@@ -40,6 +40,7 @@ fn clear_item_effects(item: &mut AppearanceItem) {
/// Does anything in `n`'s subtree need baking?
pub fn needs_bake(n: &Node) -> bool {
crate::has_revolve(n)
|| crate::has_solid(n)
|| has_adjustment(n)
|| has_crop_marks(n)
|| has_pathfinder(n)
@@ -93,7 +94,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if let Some(m) = crop_marks_art(n) {
return Some(bake_pieces(d, m));
}
if let Some(m) = crate::revolve_art(n) {
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
let m = evaluate_container(&m).unwrap_or(m);
return Some(bake_pieces(d, m));
}
@@ -232,7 +233,7 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
/// effects are dropped. `None` for other kinds of objects.
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
if let Some(m) = crate::revolve::expand_revolve_art(n) {
if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) {
let m = evaluate_container(&m).unwrap_or(m);
return Some(bake_pieces(d, m));
}
@@ -299,7 +300,7 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
pub fn bake_appearance(n: &Node) -> Option<Node> {
if let Some(m) = crate::revolve_art(n) {
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
return Some(m);
}
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
+27 -2
View File
@@ -1,6 +1,7 @@
//! Distort & Transform effects.
use serde_json::Value;
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
use crate::util::*;
@@ -107,6 +108,10 @@ const MAX_SCALE: f64 = 100_000.0;
const MAX_MOVE: f64 = 1.0e7;
/// The Transform effect's copies stop before one reaches further than this (pt) from the origin.
const MAX_REACH: f64 = 1.0e9;
/// Most anchors a Transform effect's copies make of a path, the original included: 1000 copies of
/// a 2000-anchor path. Stacked Transform effects multiply their copies (three of 1000 copies make
/// a thousand million of one square), so the limit is on the result, not on each effect's count.
pub(crate) const MAX_COPY_ANCHORS: usize = 2_000_000;
/// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds
/// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With
@@ -114,6 +119,11 @@ const MAX_REACH: f64 = 1.0e9;
/// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result
/// on every redraw while each object varies its own way.
pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
transform_within(path, b, p, seed, MAX_COPY_ANCHORS)
}
/// [`transform`] whose copies make at most `max` anchors of the path, the original included.
pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, max: usize) -> PathData {
let random = flag(p, "random", false);
// The share (0..1) of value `k` applied.
let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 };
@@ -131,9 +141,17 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData {
return path.transformed(m);
}
let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH);
// Each copy adds the path once more, and a Transform after this one copies the lot again:
// copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705).
let each = path.anchor_count();
let mut anchors = each;
let mut out = path.clone();
let mut cur = path.clone();
for _ in 0..copies {
anchors = anchors.saturating_add(each);
if anchors > max {
break;
}
cur = cur.transformed(m);
if !cur.bounds().is_some_and(within) {
break;
@@ -194,7 +212,10 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
map_nonlinear(path, pieces, f)
}
/// Zig Zag: `ridges` extra points per segment, alternately displaced along the normal.
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
/// displaced along the normal.
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
@@ -207,8 +228,12 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let mut k = 0usize;
for i in 0..segs {
let c = sp.segment(i);
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
// near the anchors, and so does any uneven curve.
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
for j in 0..=ridges {
let t = j as f64 / (ridges + 1) as f64;
let s = j as f64 / (ridges + 1) as f64;
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
let nrm = if j == 0 {
// Average the normals of the segments meeting at the anchor.
let prev = if i > 0 {
+80 -9
View File
@@ -43,6 +43,7 @@ pub mod pixel;
mod raster;
mod reshape;
mod revolve;
mod solids;
pub mod stroke;
mod stylize;
mod util;
@@ -70,6 +71,10 @@ pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
view_options,
};
pub use warp::{WarpStyle, warp_point};
/// Catalogue entry for one effect.
@@ -125,6 +130,8 @@ fn lengths_of(id: &str) -> Lengths {
"distort.transform" => always(&["moveH", "moveV"]),
"path.offsetPath" => always(&["offset"]),
"threeD.revolve" => always(&["offset"]),
"threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]),
"threeD.inflate" => always(&["depth"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
@@ -193,8 +200,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), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"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), 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), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; 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,"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}),
),
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}),
),
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}),
),
g(
"convertToShape.rectangle",
@@ -221,7 +249,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.freeDistort",
"Free Distort…",
DT,
"{corners: [[x,y]×4] new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates (identity [[0,0],[1,0],[1,1],[0,1]])}",
"{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}",
json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}),
),
g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})),
@@ -614,21 +642,64 @@ pub fn default_params(id: &str) -> Option<Value> {
}
}
/// `params` layered over the defaults of `id` (unknown keys are kept).
/// `params` layered over the defaults of `id` (unknown keys are kept), with the 3D effects'
/// earlier `perspective` read as `perspectiveAngle` (see [`upgrade_params`]).
pub fn merged_params(id: &str, params: &Value) -> Value {
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
Some(Value::Object(m)) => m.clone(),
Some(_) => Map::new(),
None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(),
};
if let Value::Object(p) = params {
for (k, v) in p {
out.insert(k.clone(), v.clone());
}
// Upgraded before the defaults are under it: a default angle is no angle given.
if let Value::Object(p) = upgrade_params(id, params) {
out.extend(p);
}
Value::Object(out)
}
/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 %
/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light
/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height
/// (`lightRotation`, `lightHeight`, `lightBehind`). Earlier values (from a file or a script of
/// then) become the current ones that show exactly the same, unless those are given too, which win.
pub fn upgrade_params(id: &str, params: &Value) -> Value {
match params {
Value::Object(m) => {
let mut m = m.clone();
upgrade_perspective(id, &mut m);
upgrade_light(id, &mut m);
Value::Object(m)
}
other => other.clone(),
}
}
fn upgrade_light(id: &str, m: &mut Map<String, Value>) {
if !is_three_d(id) {
return;
}
let (azimuth, elevation) = (m.remove("lightAzimuth"), m.remove("lightElevation"));
if azimuth.is_none() && elevation.is_none() || m.contains_key("lightRotation") || m.contains_key("lightHeight") {
return;
}
let degrees = |v: Option<Value>, default: f64| v.and_then(|v| v.as_f64()).unwrap_or(default);
let (rotation, height, behind) = vectorcraft_three_d::light_from_azimuth_elevation(degrees(azimuth, -45.0), degrees(elevation, 45.0));
m.insert("lightRotation".into(), json!(rotation));
m.insert("lightHeight".into(), json!(height));
m.insert("lightBehind".into(), json!(behind));
}
fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
if !is_three_d(id) {
return;
}
let Some(strength) = m.remove("perspective") else { return };
if !m.contains_key("perspectiveAngle") {
let angle = vectorcraft_three_d::perspective_from_strength(strength.as_f64().unwrap_or(0.0) / 100.0);
m.insert("perspectiveAngle".into(), json!(angle));
}
}
/// A new effect record with default parameters (overridden by `params`).
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
if !catalog_index().contains_key(id) {
@@ -669,7 +740,7 @@ pub fn is_geometry(id: &str) -> bool {
&& !is_pathfinder(id)
&& !is_adjustment(id)
&& id != CROP_MARKS
&& id != REVOLVE
&& !is_three_d(id)
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
}
+5 -3
View File
@@ -29,11 +29,13 @@ pub fn revolve_options(p: &Value) -> Revolve {
rotation_x: num(p, "rotationX", 0.0),
rotation_y: num(p, "rotationY", 0.0),
rotation_z: num(p, "rotationZ", 0.0),
perspective: num(p, "perspective", 0.0) / 100.0,
perspective: num(p, "perspectiveAngle", 0.0),
segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize,
light_azimuth: num(p, "lightAzimuth", -45.0),
light_elevation: num(p, "lightElevation", 45.0),
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_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,
shade: flag(p, "shade", true),
}
+273
View File
@@ -0,0 +1,273 @@
//! Extrude & Bevel, Inflate and Rotate as live, shaded vector art, shared by the canvas and every
//! exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
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_geom::{FillRule, PathData, Rect};
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry};
pub const EXTRUDE: &str = "threeD.extrude";
pub const INFLATE: &str = "threeD.inflate";
pub const ROTATE: &str = "threeD.rotate";
/// The 3D effects this module draws (Revolve has its own).
pub const SOLIDS: [&str; 3] = [EXTRUDE, INFLATE, ROTATE];
/// Is `id` one of the 3D and Materials effects (Revolve included)?
pub fn is_three_d(id: &str) -> bool {
id == crate::REVOLVE || SOLIDS.contains(&id)
}
fn solid(fx: &[Effect]) -> Option<(usize, &Effect)> {
fx.iter().enumerate().find(|(_, e)| e.visible && SOLIDS.contains(&e.id.as_str()))
}
/// Does `n` carry a visible Extrude & Bevel, Inflate or Rotate effect?
pub fn has_solid(n: &Node) -> bool {
solid(&n.appearance.effects).is_some() || n.appearance.items.iter().any(|i| i.visible() && solid(i.effects()).is_some())
}
/// The Position presets: (id, [rotation X, Y, Z]). Front, Back, Left, Right, Top and Bottom face
/// the art that way; Off-Axis tilts each 20° down and 25° aside (showing the top and right);
/// Isometric lays the art on the left, right, top or bottom plane of an isometric view (the
/// visible axes foreshortened equally: atan(1/√2) ≈ 35.26° and 45°). Our own values, from the
/// geometry of each view.
pub const ROTATION_PRESETS: [(&str, [f64; 3]); 16] = [
("front", [0.0, 0.0, 0.0]),
("back", [0.0, 180.0, 0.0]),
("left", [0.0, 90.0, 0.0]),
("right", [0.0, -90.0, 0.0]),
("top", [-90.0, 0.0, 0.0]),
("bottom", [90.0, 0.0, 0.0]),
("offAxisFront", [-21.88, -23.4, 9.06]),
("offAxisBack", [-158.12, 23.4, -170.94]),
("offAxisLeft", [-40.74, 58.39, -36.26]),
("offAxisRight", [-139.26, -58.39, 143.74]),
("offAxisTop", [-111.88, -23.4, 9.06]),
("offAxisBottom", [68.12, -23.4, 9.06]),
("isometricLeft", [-45.0, -35.26, 30.0]),
("isometricRight", [-45.0, 35.26, -30.0]),
("isometricTop", [45.0, -35.26, 30.0]),
("isometricBottom", [-45.0, -35.26, -30.0]),
];
/// The view parameters every 3D effect shares, named as Revolve's.
pub fn view_options(p: &Value) -> View {
View {
rotation_x: num(p, "rotationX", ROTATION_PRESETS[6].1[0]),
rotation_y: num(p, "rotationY", ROTATION_PRESETS[6].1[1]),
rotation_z: num(p, "rotationZ", ROTATION_PRESETS[6].1[2]),
perspective: num(p, "perspectiveAngle", 0.0),
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_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,
shade: flag(p, "shade", true),
}
}
/// Extrude & Bevel's parameters. `bevel` is `"none"` or a [`BevelShape`] id.
pub fn extrude_options(p: &Value) -> Extrude {
Extrude {
depth: num(p, "depth", 50.0),
twist: num(p, "twist", 0.0),
taper: num(p, "taper", 100.0) / 100.0,
cap: flag(p, "cap", true),
bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel {
shape,
width: num(p, "bevelWidth", 4.0),
height: num(p, "bevelHeight", 4.0),
repeat: num(p, "bevelRepeat", 1.0).clamp(1.0, 32.0) as usize,
space: num(p, "bevelSpace", 0.0) / 100.0,
inside: flag(p, "bevelInside", false),
both_sides: flag(p, "bevelBothSides", false),
}),
}
}
pub fn inflate_options(p: &Value) -> Inflate {
Inflate {
depth: num(p, "depth", 0.0),
volume: num(p, "volume", 50.0),
both_sides: flag(p, "bothSides", false),
twist: num(p, "twist", 0.0),
taper: num(p, "taper", 100.0) / 100.0,
cap: flag(p, "cap", true),
}
}
/// The shape a solid is made from: a path's or a compound path's outline.
fn profile(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
}
_ => None,
}
}
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
let view = view_options(p);
match id {
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view),
_ => vectorcraft_three_d::plane(path, rule, bounds, view),
}
}
/// 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> {
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."))?;
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
if let Some(v) = p.get("bevel")
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
{
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
return Err(format!("bevel must be none or one of {}", names.join(", ")));
}
facets(id, &path, rule, b, p).map(|_| ())
}
/// 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 {
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);
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);
edge.opacity = fill.opacity;
vec![item, AppearanceItem::Stroke(edge)]
})
.collect();
Arc::new(Node::path(n.id, face.path(), Appearance { items, ..Default::default() }))
})
.collect();
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
group
}
fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
match item {
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
}
}
/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the
/// stroke's outline (whose contours nest as its holes do) for a stroke.
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) {
match item {
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => (crate::stroke::outline_region(path, rule, s), FillRule::EvenOdd),
_ => (path.clone(), rule),
}
}
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
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);
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds, so they stay together.
let mut out = vec![];
for item in items {
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)));
}
return Some(out);
}
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
let (paint, opacity, blend) = paint_of(item);
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
// failing the frame; commands validate the same evaluator and report its errors.
let f = facets(&fx.id, path, rule, b, &p).ok()?;
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
}
/// `n` drawn with its Extrude & Bevel, Inflate or Rotate effect: a group of shaded faces keeping
/// the object's id and transparency, the source path untouched. `None` without such an effect.
pub fn solid_art(n: &Node) -> Option<Node> {
if !has_solid(n) {
return None;
}
let (path, rule) = profile(n)?;
let ctx = GeomContext::of(n);
let painted: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
let mut children = vec![];
let remaining: Vec<Effect>;
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
children.extend(art_for(n, fx, &path, rule, &painted)?);
// Later geometry effects act on the projected art as a whole.
remaining = n
.appearance
.effects
.iter()
.enumerate()
.filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index))
.map(|(_, e)| e.clone())
.collect();
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
for item in &painted {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
for mut art in art_for(n, fx, &p, rule, &[*item])? {
Arc::make_mut(&mut art).appearance.effects =
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
children.push(art);
}
} else {
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![(*item).clone()], ..Default::default() })));
}
}
}
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
Some(art)
}
#[cfg(test)]
#[path = "tests_solids.rs"]
mod tests;
+76 -2
View File
@@ -19,7 +19,7 @@ mod width;
use std::borrow::Cow;
use kurbo::{BezPath, PathEl, PathSeg};
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Shape};
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit};
@@ -116,10 +116,84 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
out
}
(None, None) => kurbo::stroke(line.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol),
(None, None) => {
let mut out = plain_outline(line, &style(st, width), tol);
out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter());
out
}
}
}
/// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as
/// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG
/// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows
/// (SVG 2, "Path data": zero-length path segments).
fn point_dots(line: &BezPath) -> Vec<Dot> {
let els = line.elements();
subpaths(line)
.into_iter()
.filter_map(|(r, _)| {
let sub = els.get(r)?;
let at = sub.first()?.end_point()?;
let still = sub.iter().all(|e| match e {
PathEl::MoveTo(p) | PathEl::LineTo(p) => *p == at,
PathEl::QuadTo(a, b) => *a == at && *b == at,
PathEl::CurveTo(a, b, c) => *a == at && *b == at && *c == at,
PathEl::ClosePath => true,
});
still.then_some(Dot { at, dir: kurbo::Vec2::new(1.0, 0.0), t: 0.0 })
})
.collect()
}
/// `line` stroked with `style`, to fill with the non-zero rule.
///
/// kurbo offsets a closed subpath to both sides, as two contours. Once half the weight is more
/// than the shape can hold (its inradius), the inner one has crossed the middle and comes out
/// the far side: wound against the outer contour it would cut a hole where the pen in fact
/// overlaps itself (#1396). Such a contour is left out, so the stroke paints solid.
// shortcut: closed subpaths of plain strokes only. An open path that turns back on itself inside
// a wider stroke (and dashes on such a shape) can still cancel; fix it in the offsetting if reported.
fn plain_outline(line: &BezPath, style: &kurbo::Stroke, tol: f64) -> BezPath {
let stroke = |els: &[PathEl]| kurbo::stroke(els.iter().copied(), style, &kurbo::StrokeOpts::default(), tol);
let els = line.elements();
// Only a stroke at least as wide as a closed subpath's bounds can cross over: everything
// else takes the one call it always did.
let may_cross = |sub: &[PathEl]| {
let b = BezPath::from_vec(sub.to_vec()).bounding_box();
style.width >= b.width().min(b.height())
};
let subs = subpaths(line);
if !subs.iter().any(|(r, closed)| *closed && els.get(r.clone()).is_some_and(may_cross)) {
return stroke(els);
}
let reach = style.width / 2.0 - 2.0 * tol;
let mut out = BezPath::new();
for (r, closed) in subs {
let Some(sub) = els.get(r) else { continue };
let outline = stroke(sub);
if !(closed && may_cross(sub)) {
out.extend(outline.iter());
continue;
}
let sub = BezPath::from_vec(sub.to_vec());
for (c, _) in subpaths(&outline) {
let Some(contour) = outline.elements().get(c) else { continue };
if reaches_edge(contour, &sub, reach) {
out.extend(contour.iter().copied());
}
}
}
out
}
/// Whether some point of `contour` lies at least `reach` from `path`: an offset contour that
/// never does is nowhere the edge of the stroke.
fn reaches_edge(contour: &[PathEl], path: &BezPath, reach: f64) -> bool {
let far = |p: kurbo::Point| path.segments().all(|s| s.nearest(p, 1e-6).distance_sq >= reach * reach);
kurbo::segments(contour.iter().copied()).any(|s| far(s.eval(0.0)) || far(s.eval(0.5)))
}
/// Append `seg` to `out` (which must already have a current point at its start).
pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) {
match seg {
+99
View File
@@ -37,6 +37,23 @@ fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
/// #1396: a stroke wider than the shape still covers the interior and reaches half the weight past it.
/// A thinner stroke keeps the hole in the middle.
#[test]
fn a_stroke_wider_than_the_shape_covers_the_interior() {
let circle = kurbo::Circle::new(Point::new(0.0, 0.0), 5.0).to_path(0.1);
let st = stroke(40.0, |_| {});
let outline = line_outline(&circle, &st, 40.0, 0.25);
let b = outline.bounding_box();
assert!(b.x0 < -24.0 && b.x1 > 24.0, "half the weight past the radius: {b:?}");
assert!(kurbo::Shape::contains(&outline, Point::ZERO), "the middle of a fat stroke is painted");
let thin = line_outline(&circle, &stroke(4.0, |_| {}), 4.0, 0.25);
assert!(!kurbo::Shape::contains(&thin, Point::ZERO), "a narrow stroke stays a ring");
assert!(kurbo::Shape::contains(&thin, Point::new(5.0, 0.0)));
let square = line_outline(&Rect::new(0.0, 0.0, 8.0, 8.0).to_path(0.1), &st, 40.0, 0.25);
assert!(kurbo::Shape::contains(&square, Point::new(4.0, 4.0)), "a fat stroke on a small rectangle covers it");
}
// ---------------------------------------------------------------- dashes and dots
#[test]
@@ -304,3 +321,85 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() {
}
assert!(dash(&line(0.0, 1e-9), &dashed(&[1e-12, 1e-12], 0.0)).is_none(), "too fine to advance: solid");
}
/// Grid points within `reach` of the inside of `path` (not past an open end) that `outline`
/// leaves unpainted.
fn unpainted_near(path: &BezPath, outline: &BezPath, reach: f64) -> Vec<Point> {
let b = path.bounding_box().inflate(reach, reach);
let ends: Vec<Point> = if is_closed(path) { vec![] } else { path.segments().flat_map(|s| [s.start(), s.end()]).collect() };
let ends = [ends.first().copied(), ends.last().copied()];
let mut missed = Vec::new();
for i in 0..=60 {
for j in 0..=60 {
let p = Point::new(b.x0 + b.width() * f64::from(i) / 60.0, b.y0 + b.height() * f64::from(j) / 60.0);
let nearest = path
.segments()
.map(|s| (s, kurbo::ParamCurveNearest::nearest(&s, p, 1e-6)))
.min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq))
.map(|(s, n)| (s.eval(n.t), n.distance_sq.sqrt()));
let Some((at, dist)) = nearest else { continue };
let past_end = ends.iter().flatten().any(|e| e.distance(at) < 1e-6);
if dist <= reach && !past_end && outline.winding(p) == 0 {
missed.push(p);
}
}
}
missed
}
/// #1396: whatever the weight is next to the size of the shape, every point within half the
/// weight of the path is painted (the stroke is the path swept by a pen that wide).
#[test]
fn a_fat_stroke_paints_everything_within_half_its_weight() {
let mut shapes: Vec<(String, BezPath)> = Vec::new();
for r in [3.0, 6.0, 10.0, 15.0, 20.0, 24.0, 40.0] {
let circle = kurbo::Circle::new(Point::ZERO, r).to_path(0.01);
let mut ellipse = kurbo::Ellipse::new(Point::ZERO, (r, r / 2.0), 0.3).to_path(0.01);
ellipse.close_path();
let arc = BezPath::from_vec(circle.elements().iter().take(3).copied().collect());
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
for p in [(r * 2.0, 0.0), (r * 2.0, r), (r, r), (r, r * 2.0), (0.0, r * 2.0)] {
l.line_to(p);
}
l.close_path();
let mut two = circle.clone();
two.extend(kurbo::Circle::new(Point::new(200.0, 0.0), 60.0).to_path(0.01).iter());
shapes.extend([
(format!("circle r={r}"), circle),
(format!("ellipse {r}"), ellipse),
(format!("rect {r}"), Rect::new(0.0, 0.0, r, r * 1.5).to_path(0.1)),
(format!("open arc r={r}"), arc),
(format!("L {r}"), l),
(format!("circle r={r} beside a large one"), two),
]);
}
let mut failures = Vec::new();
for (name, path) in &shapes {
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
let outline = line_outline(path, &stroke(50.0, |s| s.join = join), 50.0, 0.05);
// A bevel stops short of the pen's reach at a corner.
let reach = if join == LineJoin::Bevel { 16.0 } else { 24.5 };
let missed = unpainted_near(path, &outline, reach);
if !missed.is_empty() {
failures.push(format!("{name} {join:?}: {} unpainted, e.g. {:?} ({} contours)", missed.len(), missed.first(), moves(&outline)));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// A stroke the shape has room for keeps its hole, also beside a subpath that has none.
#[test]
fn a_stroke_narrower_than_the_shape_keeps_its_hole() {
let mut path = kurbo::Circle::new(Point::ZERO, 30.0).to_path(0.01);
path.extend(kurbo::Circle::new(Point::new(200.0, 0.0), 10.0).to_path(0.01).iter());
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
let outline = line_outline(&path, &stroke(50.0, |s| s.join = join), 50.0, 0.05);
assert_eq!(outline.winding(Point::ZERO), 0, "{join:?}: the hole of the wide ring");
assert_eq!(outline.winding(Point::new(4.0, 0.0)), 0, "{join:?}: up to its inner edge");
assert_ne!(outline.winding(Point::new(6.0, 0.0)), 0, "{join:?}");
assert_ne!(outline.winding(Point::new(200.0, 0.0)), 0, "{join:?}: the small circle is solid");
assert_eq!(moves(&outline), 3, "{join:?}: two contours for the ring, one for the blob");
}
}
+55 -16
View File
@@ -94,8 +94,25 @@ fn flatten(path: &PathData) -> Vec<Vec<Point>> {
polys
}
/// Scribble (simplified): a single zig-zag hatching across the filled area at `angle`, outlined
/// to `strokeWidth` so the fill paints it.
/// Whether segment `p`–`q` properly crosses an edge of `polys`: its own ends, which lie on the
/// outline, don't count, nor does running along an edge.
fn crosses_outline(p: Point, q: Point, polys: &[Vec<Point>]) -> bool {
let (p, q) = (p.lerp(q, 1e-3), q.lerp(p, 1e-3));
let side = |a: Point, b: Point, c: Point| (b - a).cross(c - a);
// Strictly on opposite sides, beyond rounding noise (the sides are areas: length × length).
let apart = |s1: f64, s2: f64, eps: f64| (s1 > eps && s2 < -eps) || (s1 < -eps && s2 > eps);
let pq = (q - p).hypot();
polys.iter().any(|pl| {
pl.windows(2).any(|w| {
let (a, b) = (w[0], w[1]);
let eps = 1e-9 * pq * (b - a).hypot();
apart(side(p, q, a), side(p, q, b), eps) && apart(side(a, b, p), side(a, b, q), eps)
})
})
}
/// Scribble (simplified): a zig-zag hatching across the filled area at `angle`, one per region
/// of the shape, outlined to `strokeWidth` so the fill paints it.
pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
let angle = num(p, "angle", 30.0).to_radians();
let overlap = num(p, "overlap", 0.0);
@@ -117,7 +134,16 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
return PathData::default();
}
let step = spacing.max((y1 - y0) / 2000.0);
let mut pts: Vec<Point> = vec![];
// One zig-zag per region (#1648): a span continues a chain of the previous row as long as the
// line from that row's end to this one's start stays inside the shape; otherwise it starts a
// chain of its own. So no line crosses a hole or a notch. In a convex shape that line always
// stays inside: one chain, as before.
struct Chain {
pts: Vec<Point>,
end: Point,
row: u64,
}
let mut chains: Vec<Chain> = vec![];
let mut y = y0 + step / 2.0;
let mut row = 0u64;
while y < y1 {
@@ -131,29 +157,42 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
}
}
xs.sort_by(f64::total_cmp);
let mut spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect();
if row % 2 == 1 {
spans.reverse();
}
let spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect();
let open = chains.len();
for (a, bx) in spans {
let jitter = |k: u64| variation * noise(seed, row, k, 3);
let (l, r) = (Point::new(a + jitter(0), y + jitter(1)), Point::new(bx + jitter(2), y + jitter(3)));
if row.is_multiple_of(2) {
pts.push(l);
pts.push(r);
} else {
pts.push(r);
pts.push(l);
let even = row.is_multiple_of(2);
let pair = if even { [l, r] } else { [r, l] };
// Un-jittered ends of the span in drawing order.
let (start, end) = if even { (Point::new(a, y), Point::new(bx, y)) } else { (Point::new(bx, y), Point::new(a, y)) };
// The nearest chain of the previous row that this span can join without leaving
// the shape.
let prev = row.checked_sub(1);
let pick = (0..open)
.filter(|&k| Some(chains[k].row) == prev && !crosses_outline(chains[k].end, start, &polys))
.min_by(|&i, &j| (chains[i].end - start).hypot().total_cmp(&(chains[j].end - start).hypot()));
match pick {
Some(k) => {
let c = &mut chains[k];
c.pts.extend(pair);
(c.end, c.row) = (end, row);
}
None => chains.push(Chain { pts: pair.to_vec(), end, row }),
}
}
row += 1;
y += step;
}
if pts.len() < 2 {
let lines: Vec<SubPath> = chains
.into_iter()
.filter(|c| c.pts.len() >= 2)
.map(|c| if curvy > 0.0 { catmull_rom(&c.pts, false, curvy * 2.0) } else { SubPath::polyline(&c.pts, false) })
.collect();
if lines.is_empty() {
return PathData::default();
}
let line = if curvy > 0.0 { catmull_rom(&pts, false, curvy * 2.0) } else { SubPath::polyline(&pts, false) };
let mut center = PathData::single(line);
let mut center = PathData::new(lines);
center.transform(to_local);
let stroke = kurbo::Stroke::new(width).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round);
let outline = kurbo::stroke(center.to_bezpath(), &stroke, &kurbo::StrokeOpts::default(), 0.05);
+129
View File
@@ -206,6 +206,24 @@ fn random_transform_is_stable_per_object_and_within_the_values() {
assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 90.0, 1e-9), "{r:?}");
}
/// #1705: stacked Transform effects multiply their copies; the anchors they make are bounded,
/// whatever each one's count.
#[test]
fn stacked_transform_copies_stay_within_the_anchor_budget() {
let p = json!({"copies": 1000});
let b = square().bounds().unwrap();
let mut path = square();
let mut counts = vec![];
for _ in 0..3 {
path = crate::distort::transform_within(&path, b, &p, 0, 100);
counts.push(path.anchor_count());
}
assert_eq!(counts, [100, 100, 100], "25 squares, then no room for a copy of them");
// Within the budget every copy is made, and the effect's own budget takes a thousand copies.
assert_eq!(crate::distort::transform_within(&square(), b, &json!({"copies": 24}), 0, 100).subpaths.len(), 25);
assert_eq!(run("distort.transform", p, &square()).subpaths.len(), 1001);
}
#[test]
fn transform_copies_stop_before_they_overflow() {
let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square());
@@ -236,6 +254,44 @@ fn roughen_seed_changes_result_and_detail_adds_points() {
assert!(bb.x0 > -8.0 && bb.x1 < 108.0);
}
#[test]
fn catmull_rom_keeps_a_turn_back_smooth() {
// #1643: the path runs out to (10, 0) and turns back to (0.5, 0.2). The Catmull-Rom tangent
// `next - prev` is almost zero there; the anchor gets handles along the chords' bisector
// (here nearly perpendicular to them), collinear and as long as half the longer chord / 6.
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.5, 0.2), Point::new(10.0, 5.0)];
let sp = util::catmull_rom(&pts, false, 1.0);
let a = sp.anchors[1];
let (hin, hout) = (a.h_in - a.p, a.h_out - a.p);
assert!(close(hin.x, -hout.x, 1e-9) && close(hin.y, -hout.y, 1e-9), "collinear: {a:?}");
assert!(close(hout.hypot(), 0.5 * 10.0 / 6.0, 1e-3), "{a:?}");
assert!(hout.x.abs() < 0.05 * hout.hypot(), "across the chords: {a:?}");
// A regular turn keeps the plain Catmull-Rom tangent.
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0)];
let a = util::catmull_rom(&pts, false, 1.0).anchors[1];
let (got, want) = (a.h_out - a.p, (pts[2] - pts[0]) / 6.0);
assert!(close(got.x, want.x, 1e-12) && close(got.y, want.y, 1e-12), "{got:?} vs {want:?}");
}
#[test]
fn roughen_smooth_makes_no_cusps() {
// #1643: wherever Roughen's jitter turns the path back, the smooth points stay smooth: no
// anchor's handles shrink below 1/12 of its longer chord (a cusp that PDF viewers miter).
let star = shapes::star(Point::new(150.0, 100.0), 70.0, 32.0, 5, 0.0);
for seed in 0..50 {
let out = run("distort.roughen", json!({"size": 3, "detail": 10, "seed": seed}), &star);
for sp in &out.subpaths {
let n = sp.anchors.len();
for i in 0..n {
let a = sp.anchors[i];
let (prev, next) = (sp.anchors[(i + n - 1) % n].p, sp.anchors[(i + 1) % n].p);
let chord = (a.p - prev).hypot().max((next - a.p).hypot());
assert!((a.h_out - a.p).hypot() >= chord / 12.0 - 1e-9, "seed {seed}: cusp at {:?}", a.p);
}
}
}
}
#[test]
fn zigzag_ridge_count_and_amplitude() {
let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square());
@@ -244,6 +300,62 @@ fn zigzag_ridge_count_and_amplitude() {
assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}");
}
#[test]
fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
// #1554: on a straight line every ridge is the same distance from the line and as far from
// the next one, whether the points are corners or smooth.
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.0, 500.0)], false));
for points in ["corner", "smooth"] {
let out = run("distort.zigZag", json!({"ridges": 4, "points": points, "size": 20}), &line);
let ps: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(ps.len(), 6, "{points}");
for (k, q) in ps.iter().enumerate() {
assert!(close(q.x.abs(), 20.0, 1e-6), "{points} {k}: {q:?}");
assert!(close(q.y, 100.0 * k as f64, 1e-3), "{points} {k}: {q:?}");
}
}
}
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
/// turn all the way round.
#[test]
fn zigzag_on_a_circle_is_regular() {
let circle = shapes::ellipse(Rect::new(-50.0, -50.0, 50.0, 50.0));
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 10}), &circle);
let pts: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts.len(), 16);
let start = pts[0].y.atan2(pts[0].x);
let mut radii = vec![];
for (i, q) in pts.iter().enumerate() {
let turn = (q.y.atan2(q.x) - start).to_degrees().abs() / 22.5;
assert!(close(turn, turn.round(), 0.02), "point {i} at {turn} sixteenths");
radii.push(q.to_vec2().hypot().round());
}
assert!(radii.iter().all(|r| *r == 60.0 || *r == 40.0) && (0..16).all(|i| radii[i] != radii[(i + 1) % 16]), "{radii:?}");
}
/// #1554 on an uneven curve: the ridges divide its length, not its parameter, into equal parts.
#[test]
fn zigzag_spaces_ridges_by_length_on_an_uneven_curve() {
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0));
let curve = PathData::single(SubPath::new(
vec![
vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)),
vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b),
],
false,
));
let c = curve.subpaths[0].segment(0);
let len = c.arclen(1e-6);
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 0}), &curve);
let along: Vec<f64> = out.subpaths[0].anchors.iter().map(|q| c.subsegment(0.0..c.nearest(q.p, 1e-9).t).arclen(1e-6) / len).collect();
assert_eq!(along.len(), 5);
for (k, share) in along.iter().enumerate() {
assert!(close(*share, k as f64 / 4.0, 1e-3), "{along:?}");
}
}
#[test]
fn offset_path_grows_and_shrinks() {
let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap();
@@ -298,6 +410,23 @@ fn twist_fixes_the_outer_circle_and_rotates_inside() {
assert!((a.y - 50.0).abs() > 1.0, "near-centre points rotate: {a:?}");
}
#[test]
fn scribble_does_not_cross_holes() {
// #1648: a U (opening at the top, the notch x 30..70, y 0..60). Rows (y 2.5, 7.5, ...) through the prongs have
// two spans; their zig-zags must stay apart instead of crossing the notch.
let u = [(0.0, 0.0), (30.0, 0.0), (30.0, 60.0), (70.0, 60.0), (70.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let u = PathData::single(SubPath::polyline(&u.map(|(x, y)| Point::new(x, y)), true));
let params = json!({"angle": 0, "variation": 0, "curviness": 0, "strokeWidth": 2, "spacing": 5});
let out = run("stylize.scribble", params, &u).to_bezpath();
for x in [40.0, 50.0, 60.0] {
for y in [12.5, 32.5, 52.5] {
assert_eq!(vectorcraft_geom::kurbo::Shape::winding(&out, Point::new(x, y)), 0, "scribble covers the notch at ({x}, {y})");
}
}
// The bar below the notch is still hatched.
assert_ne!(vectorcraft_geom::kurbo::Shape::winding(&out, Point::new(50.0, 82.5)), 0);
}
#[test]
fn scribble_produces_filled_outline_inside_bounds() {
let out = run("stylize.scribble", json!({}), &square());
+267
View File
@@ -0,0 +1,267 @@
use serde_json::json;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::{Affine, BezPath};
use super::*;
const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 };
fn square_node() -> Node {
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((200.0, 100.0));
b.line_to((200.0, 200.0));
b.line_to((100.0, 200.0));
b.close_path();
Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 4.0))
}
fn fx(id: &str, params: Value) -> Effect {
crate::new_effect(id, &params).unwrap()
}
/// The shaded faces of every surface of `art`, bottom first.
fn faces(art: &Node) -> Vec<&Node> {
art.children().unwrap().iter().flat_map(|s| s.children().unwrap().iter().map(|f| &**f)).collect()
}
fn fill_rgb(n: &Node) -> [f32; 3] {
match &n.appearance.items[0] {
AppearanceItem::Fill(f) => f.paint.color().unwrap().to_rgb(),
_ => panic!("a fill first"),
}
}
#[test]
fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
for id in SOLIDS {
let mut n = square_node();
let source = n.path_data().unwrap().clone();
n.appearance.effects.push(fx(id, json!({})));
let art = solid_art(&n).unwrap_or_else(|| panic!("{id}"));
assert_eq!(n.path_data(), Some(&source), "{id}");
assert!(!faces(&art).is_empty(), "{id}");
// The faces are the fill's red, shaded: never brighter, never another hue.
for f in faces(&art) {
let [r, g, b] = fill_rgb(f);
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
}
// The art follows the source when it moves.
let before = art.visual_bounds().unwrap();
n.transform(Affine::translate((50.0, -30.0)), false);
let after = solid_art(&n).unwrap().visual_bounds().unwrap();
assert!((after.x0 - before.x0 - 50.0).abs() < 1e-6 && (after.y0 - before.y0 + 30.0).abs() < 1e-6, "{id}");
// Hidden, the effect draws nothing of its own.
n.appearance.effects[0].visible = false;
assert!(solid_art(&n).is_none(), "{id}");
}
}
#[test]
fn rotate_turns_every_fill_and_stroke_and_the_solids_use_the_top_fill() {
let mut n = square_node();
n.appearance.effects.push(fx(ROTATE, json!({})));
let art = solid_art(&n).unwrap();
// The fill's plane and the stroke's outline, each in its own paint.
let surfaces = art.children().unwrap();
assert_eq!(surfaces.len(), 2);
let paint = |s: &Node| fill_rgb(&s.children().unwrap()[0]);
assert!(paint(&surfaces[0])[0] > paint(&surfaces[0])[1], "the red fill below");
assert!(paint(&surfaces[1]).iter().all(|c| *c < 0.05), "the black stroke above");
// Turned 60° about y with no perspective, the stroke's outline spans half its width plus the
// stroke's own outset, foreshortened as well.
n.appearance.effects[0].params = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
let b = solid_art(&n).unwrap().children().unwrap()[1].visual_bounds().unwrap();
assert!((b.width() - 104.0 * 0.5 - 0.75).abs() < 1e-6, "{b:?}");
// Extrude & Bevel paints one surface with the top fill.
let mut n = square_node();
n.appearance.effects.push(fx(EXTRUDE, json!({})));
assert_eq!(solid_art(&n).unwrap().children().unwrap().len(), 1);
}
#[test]
fn transparency_stays_on_the_whole_surface_and_items_take_their_own_effect() {
let mut n = square_node();
n.opacity = 0.5;
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
f.opacity = 0.4;
f.effects.push(fx(INFLATE, json!({})));
}
let art = solid_art(&n).unwrap();
assert_eq!(art.opacity, 0.5);
let surfaces = art.children().unwrap();
// The fill inflated, the stroke drawn flat as it was.
assert_eq!(surfaces.len(), 2);
assert_eq!(surfaces[0].opacity, 0.4);
assert!(surfaces[0].children().unwrap().iter().all(|c| c.opacity == 1.0));
assert!(matches!(surfaces[1].kind, NodeKind::Path { .. }));
}
#[test]
fn export_and_expand_bake_faces_with_their_own_ids() {
for id in SOLIDS {
let mut d = Document::new(400.0, 400.0);
let mut n = square_node();
n.id = d.alloc_id();
n.appearance.effects.push(fx(id, json!({"bevel": "rounded"})));
assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}");
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
let mut ids = std::collections::HashSet::new();
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
assert!(ids.insert(n.id), "{:?} twice", n.id);
assert!(!has_solid(n));
for c in n.children().into_iter().flatten() {
visit(c, ids);
}
}
visit(&art, &mut ids);
assert!(ids.len() > 2, "{id}");
}
}
/// A twisted, tapered, hollow Inflate (both sides) exports and expands like the others: faces with
/// their own ids, no 3D effect left on them.
#[test]
fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
let mut d = Document::new(400.0, 400.0);
let mut n = square_node();
n.id = d.alloc_id();
let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35});
n.appearance.effects.push(fx(INFLATE, params));
let art = solid_art(&n).unwrap();
assert_eq!(crate::bake_appearance(&n), Some(art.clone()));
assert!(faces(&art).len() > 20, "{} faces", faces(&art).len());
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
let mut ids = std::collections::HashSet::new();
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
assert!(ids.insert(n.id), "{:?} twice", n.id);
assert!(!has_solid(n));
for c in n.children().into_iter().flatten() {
visit(c, ids);
}
}
visit(&expanded, &mut ids);
assert!(ids.len() > 20, "{} nodes", ids.len());
}
/// Files and scripts from before the Perspective angle give `perspective` as a 0–100 % strength:
/// each 3D effect draws it exactly as the angle that stands for it, an angle given too wins, and
/// the current parameters carry only the angle.
#[test]
fn an_earlier_perspective_strength_draws_as_its_angle() {
let art = |n: &Node, id: &str| if id == crate::REVOLVE { crate::revolve_art(n) } else { solid_art(n) };
for id in SOLIDS.iter().copied().chain([crate::REVOLVE]) {
let view = |extra: Value| {
let mut p = json!({"rotationX": -25, "rotationY": 35, "rotationZ": 0, "depth": 80});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
};
let with = |params: Value| {
let mut n = square_node();
// As read from a file: the stored parameters, not a fresh effect's.
n.appearance.effects.push(Effect { id: id.into(), params, visible: true });
art(&n, id).unwrap().visual_bounds().unwrap()
};
let angle = vectorcraft_three_d::perspective_from_strength(0.6);
let old = with(view(json!({"perspective": 60})));
let new = with(view(json!({"perspectiveAngle": angle})));
let none = with(view(json!({})));
for (a, b) in [(old.x0, new.x0), (old.y0, new.y0), (old.x1, new.x1), (old.y1, new.y1)] {
assert!((a - b).abs() < 1e-9, "{id}: {old:?} against {new:?}, none {none:?}");
}
assert!((old.width() - none.width()).abs() > 1.0 || (old.height() - none.height()).abs() > 1.0, "{id}: perspective shows");
assert_eq!(with(view(json!({"perspective": 60, "perspectiveAngle": 0}))), none, "{id}: the angle wins");
let merged = crate::merged_params(id, &view(json!({"perspective": 60})));
assert!(merged.get("perspective").is_none() && (merged["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, "{id}");
}
}
/// Effects from before Rotation and Height set the light by azimuth and elevation: each 3D effect
/// shades its faces exactly as with the Rotation, Height and side that stand for it.
#[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());
}
for c in n.children().into_iter().flatten() {
colours(c, out);
}
}
for id in SOLIDS.iter().copied().chain([crate::REVOLVE]) {
for (azimuth, elevation) in [(-45.0, 45.0), (150.0, -30.0), (60.0, 80.0), (-120.0, 10.0)] {
let with = |light: Value| {
let mut p = json!({"rotationX": -25, "rotationY": 35, "rotationZ": 0, "depth": 60, "lightSoftness": 0});
p.as_object_mut().unwrap().extend(light.as_object().unwrap().clone());
let mut n = square_node();
n.appearance.effects.push(Effect { id: id.into(), params: p, visible: true });
let art = if id == crate::REVOLVE { crate::revolve_art(&n) } else { solid_art(&n) }.unwrap();
let mut out = vec![];
colours(&art, &mut out);
out
};
let (r, h, behind) = vectorcraft_three_d::light_from_azimuth_elevation(azimuth, elevation);
let old = with(json!({"lightAzimuth": azimuth, "lightElevation": elevation}));
let new = with(json!({"lightRotation": r, "lightHeight": h, "lightBehind": behind}));
assert!(!old.is_empty() && old.len() == new.len(), "{id}");
for (a, b) in old.iter().zip(&new) {
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6), "{id} {azimuth} {elevation}: {a:?} against {b:?}");
}
}
let merged = crate::merged_params(id, &json!({"lightAzimuth": 90, "lightElevation": 0}));
assert!(
merged.get("lightAzimuth").is_none() && merged["lightHeight"].as_f64().unwrap().abs() < 1e-9 && merged["lightBehind"] == json!(false),
"{id}: {merged}"
);
}
}
#[test]
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
let mut n = square_node();
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspectiveAngle": 0})));
// Straight on, the extrusion shows its front: the offset square.
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
n.appearance.effects.push(fx("distort.twist", json!({"angle": 30})));
let art = solid_art(&n).unwrap();
assert!(art.appearance.effects.iter().any(|e| e.id == "distort.twist"), "applied to the result as a whole");
assert!(art.appearance.effects.iter().all(|e| e.id != "path.offsetPath" && !is_three_d(&e.id)));
}
#[test]
fn parameters_are_validated_and_lengths_scale_with_the_object() {
let n = square_node();
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "rounded", "depth": 80})).is_ok());
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "fancy"})).unwrap_err().contains("bevel must be"));
let text = Node::group(NodeId(2), vec![]);
assert!(validate_solid(&text, INFLATE, &json!({})).unwrap_err().contains("requires a path"));
for (id, key) in [(EXTRUDE, "depth"), (EXTRUDE, "bevelWidth"), (EXTRUDE, "bevelHeight"), (INFLATE, "depth")] {
assert!(crate::is_length(id, key, false), "{id} {key}");
}
assert!(!crate::is_length(EXTRUDE, "twist", false));
for id in SOLIDS {
assert!(!crate::is_geometry(id) && is_three_d(id));
assert!(crate::default_params(id).is_some());
}
// Options read from parameters, with the documented ranges' defaults.
let o = extrude_options(&json!({"taper": 40, "bevel": "step", "bevelSpace": 30, "bevelRepeat": 99}));
assert_eq!(o.taper, 0.4);
let b = o.bevel.unwrap();
assert_eq!((b.shape, b.space, b.repeat), (BevelShape::Step, 0.3, 32));
assert!(extrude_options(&json!({})).bevel.is_none());
}
#[test]
fn inflate_reads_twist_taper_and_cap() {
let o = inflate_options(&json!({"twist": 90, "taper": 40, "cap": false}));
assert_eq!((o.twist, o.taper, o.cap), (90.0, 0.4, false));
let d = inflate_options(&json!({}));
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
}
+27 -2
View File
@@ -72,7 +72,7 @@ pub fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
/// Non-linear path mapping (shared with live envelopes in `vectorcraft-doc`).
pub use vectorcraft_doc::live::map_nonlinear;
/// Smooth subpath through `pts` (Catmull-Rom tangents).
/// Smooth subpath through `pts` (Catmull-Rom tangents, see [`turn_tangent`] for sharp turns).
pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath {
let n = pts.len();
if n < 3 {
@@ -82,13 +82,38 @@ pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath {
.map(|i| {
let prev = if i == 0 { if closed { pts[n - 1] } else { pts[0] } } else { pts[i - 1] };
let next = if i == n - 1 { if closed { pts[0] } else { pts[n - 1] } } else { pts[i + 1] };
let t: Vec2 = (next - prev) * (tension / 6.0);
let t: Vec2 = turn_tangent(prev, pts[i], next) * (tension / 6.0);
Anchor::with_handles(pts[i], pts[i] - t, pts[i] + t)
})
.collect();
SubPath::new(anchors, closed)
}
/// The Catmull-Rom tangent `next − prev` at `p`. Where the path turns back sharply, `next`
/// nearly meets `prev` and that tangent shrinks to nothing: the anchor would be a cusp, which
/// PDF viewers draw with a miter spike (#1643). So it is kept at least half as long as the
/// longer chord, and there it follows the bisector of the two chords (the perpendicular of the
/// incoming one when the path reverses exactly): the turn becomes a round hairpin. Turns of
/// less than about 150° keep the plain Catmull-Rom tangent.
fn turn_tangent(prev: Point, p: Point, next: Point) -> Vec2 {
let t = next - prev;
let (a, b) = (p - prev, next - p);
let floor = 0.5 * a.hypot().max(b.hypot());
if t.hypot() >= floor {
return t;
}
let unit = |v: Vec2| {
let l = v.hypot();
if l > 1e-12 { v / l } else { Vec2::ZERO }
};
let (u, v) = (unit(a), unit(b));
let mut d = unit(u + v);
if d == Vec2::ZERO {
d = Vec2::new(-u.y, u.x);
}
if d == Vec2::ZERO { t } else { d * floor }
}
/// Unit normal (left of travel direction in y-down coordinates) of cubic `c` at `t`.
pub fn normal_at(c: &CubicBez, t: f64) -> Vec2 {
let eps = 1e-4;
+2 -2
View File
@@ -22,10 +22,10 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
let f = |q: Point| {
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
let (x2, y2) = if vertical {
let (a, b2) = warp_point(style, bend, dh, dv, y, x);
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
(b2, a)
} else {
warp_point(style, bend, dh, dv, x, y)
warp_point(style, bend, dh, dv, hh / hw, x, y)
};
Point::new(c.x + x2 * hw, c.y + y2 * hh)
};
+1
View File
@@ -26,6 +26,7 @@ vectorcraft-eps = { workspace = true }
vectorcraft-metafile = { workspace = true }
vectorcraft-plugins = { workspace = true }
vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
kurbo = { workspace = true }
+11 -11
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Fill",
["Window", "Appearance"],
None,
"{ids?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)",
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)",
has_doc,
|s, p| add_item(s, p, true)
),
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Stroke",
["Window", "Appearance"],
None,
"{ids?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)",
"{ids?|id?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)",
has_doc,
|s, p| add_item(s, p, false)
),
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear Appearance",
["Window", "Appearance"],
None,
"{ids?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode",
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode",
has_doc,
clear_appearance
),
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reduce to Basic Appearance",
["Window", "Appearance"],
None,
"{ids?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
"{ids?|id?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
has_doc,
reduce_basic
),
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Appearance Item",
[],
None,
"{index: paint-order item index, ids? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of each target object's own appearance stack",
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} edit one fill/stroke of each target object's own appearance stack",
has_doc,
set_item
),
@@ -81,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Item",
[],
None,
"{index | indices: [..] (paint-order item indices), ids?, target?} remove those fills/strokes from each target object's own stack",
"{index | indices: [..] (paint-order item indices), ids?|id?, target?} remove those fills/strokes from each target object's own stack",
has_doc,
remove_item
),
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Effect",
[],
None,
"same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}",
"{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}",
has_doc,
super::effectcmd::apply
),
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Item",
["Window", "Appearance"],
None,
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?, target?} copy fills/strokes with their effects",
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?|id?, target?} copy fills/strokes with their effects",
has_doc,
duplicate_item
),
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reorder Appearance Item",
[],
None,
"{from: paint-order item index | \"contents\", to, contents?: count, ids?, target?} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
"{from: paint-order item index | \"contents\", to, contents?: count, ids?|id?, target?} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
has_doc,
move_item
),
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Show All Hidden Attributes",
["Window", "Appearance"],
None,
"{ids?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}",
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}",
has_doc,
show_all_hidden
),
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Target Contents",
[],
None,
"{ids?} select the members of the selected groups and layers (or `ids`), as double-clicking the Appearance panel's Contents row does: the panel then lists, and appearance edits change, their own appearance; errors when none has members → {ids}",
"{ids?|id?, target?: \"object\"|\"contents\"} select the members of the selected groups and layers (or `ids`), as double-clicking the Appearance panel's Contents row does: the panel then lists, and appearance edits change, their own appearance; errors when none has members → {ids}",
has_doc,
target_contents
),
+2 -2
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Attributes",
[],
None,
"{overprintFill?: bool, overprintStroke?: bool (aimed as object.setOverprint's fill and stroke, item? too), showCenter?: bool (the canvas shows the selected object's centre point), imageMap?: \"none\"|\"rectangle\"|\"polygon\", url?: string (\"\" removes it; SVG export links the object), note?: string, ids?} set the Attributes panel's values on ids or the selection, as one undo step; a URL given joins the recent URLs (attributes.info) → {changed: objects}",
"{overprintFill?: bool, overprintStroke?: bool (aimed as object.setOverprint's fill and stroke, item? too), showCenter?: bool (the canvas shows the selected object's centre point), imageMap?: \"none\"|\"rectangle\"|\"polygon\", url?: string (\"\" removes it; SVG export links the object), note?: string, ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} set the Attributes panel's values on ids or the selection, as one undo step; a URL given joins the recent URLs (attributes.info) → {changed: objects}",
has_doc,
set
),
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill Rule",
[],
None,
"{rule: \"nonZero\"|\"evenOdd\", ids?} the fill rule of the paths and compound paths in ids or the selection (groups: those inside), as one undo step → {changed}",
"{rule: \"nonZero\"|\"evenOdd\", ids?|id?} the fill rule of the paths and compound paths in ids or the selection (groups: those inside), as one undo step → {changed}",
has_doc,
set_fill_rule
),
+27 -29
View File
@@ -15,6 +15,7 @@ use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKin
use vectorcraft_geom::{Affine, Point, Vec2};
use super::edit::selected_roots;
use super::pathops::{remove_each, separate};
use super::*;
use crate::EngineError;
@@ -33,11 +34,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Brush",
[],
None,
"{name, ids?} apply a brush to the strokes of the selected paths (adds a stroke if missing) and make it current → {ids}",
"{name, ids?|id?} apply a brush to the strokes of the selected paths (adds a stroke if missing) and make it current → {ids}",
has_doc,
brush_apply
),
cmd!("brush.remove", "Remove Brush Stroke", [], None, "{ids?} remove brushes from the selected strokes → {ids}", has_selection, brush_remove),
cmd!(
"brush.remove",
"Remove Brush Stroke",
[],
None,
"{ids?|id?} remove brushes from the selected strokes → {ids}",
has_selection,
brush_remove
),
cmd!(
"brush.setCurrent",
"Current Brush",
@@ -52,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Brush…",
[],
None,
"{type: calligraphic|scatter|art|pattern|bristle, name?, params?: {…definition fields}, ids?} 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?, 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
),
@@ -72,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Brush Strokes",
[],
None,
"{ids?} replace brushed paths (in the selection) with groups of the brush art → {ids}",
"{ids?|id?} replace brushed paths (in the selection) with groups of the brush art → {ids}",
has_selection,
expand_brush
),
@@ -81,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paintbrush",
[],
None,
"{…path.freehand params, brush} paint a freehand stroke with a brush → {id}",
"{…params as path.freehand, brush} paint a freehand stroke with a brush → {id}",
has_doc,
brush_freehand
),
@@ -92,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Symbol…",
[],
None,
"{name?, ids?} make a symbol from the selection and replace it with an instance → {name, id}",
"{name?, ids?|id?} make a symbol from the selection and replace it with an instance → {name, id}",
has_selection,
symbol_new
),
@@ -110,7 +119,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Break Link to Symbol",
[],
None,
"{ids?} expand selected instances into plain art → {ids}",
"{ids?|id?} expand selected instances into plain art → {ids}",
has_selection,
symbol_break_link
),
@@ -119,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Symbol",
[],
None,
"{id?} break the link of the selected instance so its art can be edited; finish with symbol.update → {name, ids}",
"{id?, ids?: [id…] (instead of id)} break the link of the selected instance so its art can be edited; finish with symbol.update → {name, ids}",
has_selection,
symbol_edit
),
@@ -128,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Redefine Symbol",
[],
None,
"{name, ids?} replace the symbol's art with the selection (instances update; the selection becomes an instance) → {name, id}",
"{name, ids?|id?} replace the symbol's art with the selection (instances update; the selection becomes an instance) → {name, id}",
has_selection,
symbol_update
),
@@ -147,7 +156,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Replace Symbol",
[],
None,
"{name, ids?} swap the symbol of the selected instances → {ids}",
"{name, ids?|id?} swap the symbol of the selected instances → {ids}",
has_selection,
symbol_replace
),
@@ -517,14 +526,6 @@ fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
Ok((art, (w, h), c))
}
fn unique_symbol_name(d: &Document, base: &str) -> String {
let taken = |n: &str| d.symbols.iter().any(|s| s.name == n);
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
@@ -561,6 +562,10 @@ fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id.
fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result<NodeId> {
let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
if separate(d, ids) {
let id = d.alloc_id();
return Ok(d.wrap(ids, |_| Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?);
}
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let id = d.alloc_id();
d.insert(par, idx + 1, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf }))?;
@@ -574,7 +579,7 @@ 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 = unique_symbol_name(&s.doc()?.doc, 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").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
let id = s.edit("New Symbol", |d, sel| {
let art = d.reid(&art);
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
@@ -734,9 +739,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
if expand {
break_links(d, &inst)?;
} else {
for i in inst {
d.remove(i)?;
}
remove_each(d, &inst)?;
}
d.symbols.retain(|x| x.name != name);
set_natural_size(d, &name, None);
@@ -753,7 +756,7 @@ 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 = unique_symbol_name(d, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let size = natural_size(d, &name);
s.edit("Duplicate Symbol", |d, _| {
let art = d.reid(&sym.art);
@@ -863,12 +866,7 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
.collect();
let n = doomed.len();
if n > 0 {
s.edit("Symbol Sprayer", |d, _| {
for id in &doomed {
d.remove(*id)?;
}
Ok(())
})?;
s.edit("Symbol Sprayer", |d, _| remove_each(d, &doomed))?;
}
return Ok(json!({ "count": n }));
}
+134 -43
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
@@ -26,7 +26,7 @@ use vectorcraft_trace as tr;
use super::edit::selected_roots;
use super::paint::paint_from;
use super::pathops::shape_node;
use super::pathops::{Beside, shape_node};
use super::*;
/// Name of an Image Trace group.
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder",
[],
None,
"{ids?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)",
has_selection_or_ids,
sb_merge
),
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder Regions",
[],
None,
"{ids?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
"{ids?|id?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes",
has_selection_or_ids,
sb_regions
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Live Paint"],
Some("Cmd+Alt+X"),
"{ids?} build a Live Paint group from the selected paths → {id, faces, edges}",
"{ids?|id?} build a Live Paint group from the selected paths → {id, faces, edges}",
has_selection_or_ids,
lp_make
),
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Merge",
["Object", "Live Paint"],
None,
"{ids?} add the other selected paths (and Live Paint groups) to the first Live Paint group, keeping its paint → {id, faces, edges}",
"{ids?|id?} add the other selected paths (and Live Paint groups) to the first Live Paint group, keeping its paint → {id, faces, edges}",
has_multi_or_ids,
lp_merge
),
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Release",
["Object", "Live Paint"],
None,
"{id?} replace Live Paint groups with their original paths → {ids}",
"{id?, ids?: [id…] (instead of id)} replace Live Paint groups with their original paths → {ids}",
has_selection_or_ids,
lp_release
),
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand",
["Object", "Live Paint"],
None,
"{id?} turn Live Paint groups into plain groups of their painted faces and edges → {ids}",
"{id?, ids?: [id…] (instead of id)} turn Live Paint groups into plain groups of their painted faces and edges → {ids}",
has_selection_or_ids,
lp_expand
),
@@ -93,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Bucket",
[],
None,
"{group?: id | ids?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill)} fill the face containing point → {group, face}",
"{group?: id | ids?|id?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}",
has_doc,
lp_fill
),
@@ -102,7 +102,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Bucket (Stroke)",
[],
None,
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4)} paint the edge nearest point → {group, edge}",
"{group: id, point: [x,y], color?|swatch?|none? (default: current stroke), width?: pt (default: current weight), tolerance?: pt (4), tint?: 0..100, keepModel?: bool (both as paint.setFill)} paint the edge nearest point → {group, edge}",
has_doc,
lp_stroke_edge
),
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Paint Info",
[],
None,
"{group} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}",
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}",
has_doc,
lp_info
),
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), 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, snapCurvesToLines, 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)} → {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}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make and Expand",
["Object", "Image Trace"],
None,
"same as imageTrace.make, then expand → {id, paths, anchors, colors}",
"{…as imageTrace.make} same as imageTrace.make, then expand → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, true)
),
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Release",
["Object", "Image Trace"],
None,
"{id?} put the source image back in place of Image Trace groups → {ids}",
"{id?, ids?: [id…] (instead of id)} put the source image back in place of Image Trace groups → {ids}",
has_selection_or_ids,
trace_release
),
@@ -147,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand",
["Object", "Image Trace"],
None,
"{id?} keep only the traced paths (drop the source image) → {ids}",
"{id?, ids?: [id…] (instead of id)} keep only the traced paths (drop the source image) → {ids}",
has_selection_or_ids,
trace_expand
),
@@ -160,8 +160,8 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection_or_ids,
trace_set_view
),
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params, enhanced: the params of the preset's enhanced version, null if it has none}]}", always, |_, _| {
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p, "enhanced": tr::enhanced_preset(n) })).collect::<Vec<_>>() }))
}),
]
}
@@ -341,19 +341,16 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
/// (Roots go first: the new nodes may reuse their ids, e.g. Live Paint sources.)
fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result<Vec<NodeId>> {
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let before = roots.iter().filter(|r| **r != top && d.position(**r).is_some_and(|(p, i, _)| p == par && i < idx)).count();
for r in roots {
let Some((&top, rest)) = roots.split_last() else { return Err(EngineError::Other("nothing selected".into())) };
let ids = nodes.iter().map(|n| n.id).collect();
let mut replaced = Beside::replacing(d, roots);
for r in rest {
if d.node(*r).is_some() {
d.remove(*r)?;
replaced.put(d, *r, vec![])?;
}
}
let at = idx - before;
let mut ids = vec![];
for (k, n) in nodes.into_iter().enumerate() {
ids.push(d.insert(par, at + k, n)?);
}
replaced.put(d, top, nodes)?;
replaced.finish(d)?;
Ok(ids)
}
@@ -508,14 +505,14 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
Arc::make_mut(e).appearance = app.into();
}
}
let mut merged = Beside::replacing(d, &roots);
for r in roots.iter().filter(|r| **r != target) {
if d.node(*r).is_some() {
d.remove(*r)?;
merged.put(d, *r, vec![])?;
}
}
let (par, idx, _) = d.position(target).ok_or(EngineError::NoNode(target))?;
d.remove(target)?;
d.insert(par, idx, g)?;
merged.put(d, target, vec![g])?;
merged.finish(d)?;
sel.set([target]);
Ok((target, nf, ne))
})?;
@@ -536,11 +533,14 @@ fn lp_release(s: &mut Session, p: &Value) -> Result<Value> {
let targets = live_targets(s, p)?;
let ids = s.edit("Release Live Paint", |d, sel| {
let mut out = vec![];
let mut released = Beside::replacing(d, &targets);
for g in &targets {
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
let srcs: Vec<Node> = source_nodes(&n).iter().map(|c| (**c).clone()).collect();
out.extend(replace_roots(d, &[*g], srcs)?);
out.extend(srcs.iter().map(|c| c.id));
released.put(d, *g, srcs)?;
}
released.finish(d)?;
sel.set(out.iter().copied());
Ok(out)
})?;
@@ -657,7 +657,10 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
let name = str_param(p, "preset").unwrap_or("Default");
let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
// (A preset without an enhanced version is traced as it is.)
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
@@ -703,17 +706,93 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
/// refused with advice instead (#525).
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`.
fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape {
use vectorcraft_geom::Affine;
let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians());
let mut live = match shape {
tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle {
w: width,
h: height,
radii: [radius; 4],
kinds: Default::default(),
xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)),
},
tr::TracedShape::Ellipse { center, rx, ry, rotation } => {
LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) }
}
tr::TracedShape::Polygon { center, radius, sides, rotation } => {
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] }
}
};
// (Whether the move changed the shape's own numbers does not matter: its path is made from them next.)
let _ = live.transform(xf);
live
}
/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the
/// document by `xf`.
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint))
}
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
/// inside it (`parents`, as the tracer gives them) in a group with them, in the order they had:
/// abutting paths painted in another order would show a seam where they meet. Without parents,
/// the nodes as they are.
fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec<Arc<Node>> {
let n = nodes.len();
let mut inside: Vec<Vec<usize>> = vec![vec![]; n];
let mut roots = vec![];
for i in 0..n {
match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) {
Some(of) => of.push(i),
None => roots.push(i),
}
}
// Outermost first, so that backwards each path comes after those inside it.
let mut order = roots.clone();
let mut at = 0;
while let Some(&i) = order.get(at) {
order.extend(inside.get(i).into_iter().flatten());
at += 1;
}
let mut nodes: Vec<Option<Node>> = nodes.into_iter().map(Some).collect();
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
for &i in order.iter().rev() {
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
let mut within: Vec<(usize, Arc<Node>)> = inside.get(i).into_iter().flatten().filter_map(|&c| Some((c, built.get_mut(c)?.take()?))).collect();
let made = if within.is_empty() {
node
} else {
within.push((i, Arc::new(node)));
within.sort_by_key(|m| m.0);
Node::group(d.alloc_id(), within.into_iter().map(|m| m.1).collect())
};
if let Some(slot) = built.get_mut(i) {
*slot = Some(Arc::new(made));
}
}
// (Paths that are inside each other in a ring are reached from no root: the tracer makes none,
// and they are kept all the same.)
let ring = nodes.into_iter().flatten().map(Arc::new).collect::<Vec<_>>();
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
}
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let mut params = trace_params(p)?;
let view = view_param(C, p)?;
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
// The preset's name if the parameters are exactly that preset's, else "Custom".
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
// else "Custom".
let named = str_param(p, "preset").unwrap_or("Default");
let preset_label = match tr::preset(named) {
Some(b) if serde_json::to_value(&b).ok().as_ref() == Some(&params_json) => named.to_string(),
_ => "Custom".to_string(),
};
let is = |b: Option<tr::TraceParams>| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(&params_json);
let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() };
let roots = root_ids(s, p)?;
// The image (inside a trace group, or selected directly).
let (target, img) = {
@@ -763,17 +842,25 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
hidden.visible = false;
children.push(Arc::new(hidden));
}
let mut traced = Vec::with_capacity(res.paths.len());
for tp in res.paths {
let path = tp.path.transformed(xf);
let mut n = shape_node(d, path, None);
// Shapes: a path the tracer found to be a shape is that live shape.
if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) {
let shape = traced_shape(shape, xf);
*path = shape.to_path();
*live = Some(Box::new(shape));
}
// A library colour fills with the swatch's own colour (its model kept).
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
let paint = Paint::solid(color);
// Gradients: a path the tracer found to be a linear gradient is filled with it.
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
n.appearance = match tp.stroke {
// Traced lines: round ends and corners, as drawn lines have.
// Traced lines: round corners, and ends round or square as the tracer found them.
Some(width) => Appearance {
items: vec![AppearanceItem::Stroke(StrokeLayer {
cap: LineCap::Round,
cap: if tp.square_caps { LineCap::Square } else { LineCap::Round },
join: LineJoin::Round,
..StrokeLayer::new(paint, width * unit)
})],
@@ -782,8 +869,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
.into(),
None => fill_only(paint).into(),
};
children.push(Arc::new(n));
traced.push(n);
}
children.extend(grouped(d, traced, &res.parents));
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
@@ -836,12 +924,15 @@ fn trace_release(s: &mut Session, p: &Value) -> Result<Value> {
let targets = trace_targets(s, p)?;
let ids = s.edit("Release Image Trace", |d, sel| {
let mut out = vec![];
let mut released = Beside::replacing(d, &targets);
for g in &targets {
let n = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?;
let mut img = (**n.children().and_then(|c| c.first()).ok_or(EngineError::NoNode(*g))?).clone();
img.visible = true;
out.extend(replace_roots(d, &[*g], vec![img])?);
out.push(img.id);
released.put(d, *g, vec![img])?;
}
released.finish(d)?;
sel.set(out.iter().copied());
Ok(out)
})?;
+1 -1
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
use vectorcraft_geom::Rect;
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub use flavours::{BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub(crate) use resources::SwatchChoices;
use super::*;
+34 -5
View File
@@ -1,5 +1,5 @@
//! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what
//! Paste takes from them (bitmaps, plain text, PDF and Windows metafiles).
//! Paste takes from them (bitmaps, plain text, PDF, EPS and Windows metafiles).
//!
//! The UI owns the platform clipboard (`Services::system_clipboard` in the egui frontend): on Copy
//! it publishes [`Session::clipboard_flavours`]; before a Paste it turns what another app put on
@@ -27,9 +27,12 @@ pub const BITMAP: &str = "image/*";
/// An EMF (or WMF) picture: what Windows apps copy as vector art (the system clipboard reads it
/// where the platform offers it).
pub const EMF: &str = "image/emf";
/// An EPS (or other PostScript) picture: what drawing and typesetting apps copy as vector art
/// beside, or instead of, a PDF (#1499).
pub const EPS: &str = "application/postscript";
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
/// (word processors offer a picture of copied text too).
pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP];
pub const PASTE_ORDER: [&str; 6] = [SVG, PDF, EPS, EMF, TEXT, BITMAP];
/// Is `text` only an address (a web page's, an image's, a file's)? A browser's Copy Image puts the
/// image's address as text next to the picture: then the picture is what Paste takes (#597). An
/// address alone pastes as text, and nothing is fetched.
@@ -67,13 +70,14 @@ fn only_images(html: &str) -> bool {
pub const FILE_HEAD: usize = 4096;
/// What a file copied in a file manager pastes as, if one of `mimes` (`image/*`: any bitmap):
/// SVG, PDF, a Windows metafile or a bitmap (its own `image/…` type), told from its first
/// SVG, PDF, EPS, a Windows metafile or a bitmap (its own `image/…` type), told from its first
/// [`FILE_HEAD`] bytes and its name. The file's bytes are that flavour's data.
pub fn file_flavour(name: &str, head: &[u8], mimes: &[&str]) -> Option<&'static str> {
let f = fileio::detect(name, head)?;
let (asked, mime) = match f.id {
"svg" => (SVG, SVG),
"pdf" => (PDF, PDF),
"eps" => (EPS, EPS),
"emf" | "wmf" => (EMF, EMF),
_ if f.raster => (BITMAP, f.mime),
_ => return None,
@@ -167,6 +171,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
import_emf
),
cmd!(
query "clipboard.importEps",
"Load EPS into Clipboard",
[],
None,
"{dataBase64 (EPS or other PostScript bytes), center?: [x, y]} replace the clipboard with the picture's objects as document.open reads an EPS (editable paths, type and images, with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
has_doc,
import_eps
),
]
}
@@ -345,10 +358,26 @@ fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
if vectorcraft_metafile::sniff(&bytes).is_none() {
return Err(bad(C, "the data isn't an EMF or WMF picture"));
}
let loaded = fileio::load("Clipboard.emf", &bytes).map_err(|e| bad(C, e.to_string()))?;
import_picture(s, p, C, "Clipboard.emf", &bytes)
}
fn import_eps(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "clipboard.importEps";
let bytes = data_param(p, C)?;
if !vectorcraft_pdf::is_postscript(&bytes) {
return Err(bad(C, "the data isn't an EPS or PostScript picture"));
}
import_picture(s, p, C, "Clipboard.eps", &bytes)
}
/// Replace the clipboard with the objects of the picture `bytes`, read as the file `name` is
/// opened (command `cmd`).
fn import_picture(s: &mut Session, p: &Value, cmd: &str, name: &str, bytes: &[u8]) -> Result<Value> {
let mut loaded = fileio::load(name, bytes).map_err(|e| bad(cmd, e.to_string()))?;
loaded.doc.drop_edit_modes();
let clip = Clipboard::from_document(&loaded.doc);
if clip.is_empty() {
return Err(bad(C, "the picture has no objects"));
return Err(bad(cmd, "the picture has no objects"));
}
let count = s.load_clipboard(clip, p)?;
Ok(json!({ "count": count, "warnings": loaded.warnings }))
+1 -1
View File
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Color Guide",
[],
None,
"{color, rule: complementary|complementary2|splitComplementary|leftComplement|rightComplement|analogous|analogous2|monochromatic|shades|triad|triad2|triad3|tetrad|tetrad2|tetrad3|compound|compound2|highContrast|highContrast2|highContrast3|pentagram (or its label), steps?: 1..20 (4), variation?: \"tintsShades\"|\"warmCool\"|\"vividMuted\", amount?: 0..100 (50; how far the outermost steps go), limitTo?: swatch library id or name, or \"document\" (the document's swatches): every colour snaps to its nearest colour there (ΔE 2000)} the Color Guide for a base colour → {rule, colors: [\"#rrggbb\", the base first], grid: [per colour, 2·steps+1 variations from shades/cool/muted to tints/warm/vivid with the colour itself in the centre]}",
"{color, rule: complementary|complementary2|splitComplementary|leftComplement|rightComplement|analogous|analogous2|monochromatic|shades|triad|triad2|triad3|tetrad|tetrad2|tetrad3|compound|compound2|highContrast|highContrast2|highContrast3|pentagram (or its label), steps?: 1..20 (4), variation?: \"tintsShades\"|\"warmCool\"|\"vividMuted\", amount?: 0..100 (50; how far the outermost steps go), limitTo?: swatch library id or name; or \"document\" (the document's swatches): every colour snaps to its nearest colour there (ΔE 2000)} the Color Guide for a base colour → {rule, colors: [\"#rrggbb\", the base first], grid: [per colour, 2·steps+1 variations from shades/cool/muted to tints/warm/vivid with the colour itself in the centre]}",
always,
harmony
),
+2 -2
View File
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Convert Document Color Mode",
[],
None,
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?, grays?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
"{…as file.documentColorMode} same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?, grays?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
has_doc,
convert_mode
),
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gamut Warning",
[],
None,
"{ids?, colors?: [color]} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
"{ids?|id?, colors?: [color]} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}",
has_doc,
gamut_check
),
+1 -1
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Theme",
["Window", "Color Themes"],
None,
"{colors: [1–5 colours] | color + rule (a harmony rule id or label, see color.harmony: its 5-colour theme from that base colour, base first), rule?: the rule it was made with, name?: (default \"Theme N\"), replace?: a saved theme's name (overwrite that theme in place, e.g. after editing it; it may be renamed)} save a colour theme to the local theme library (the preferences) → {name, count: themes saved}",
"{colors: [1–5 colours] | color + rule (a harmony rule id or label, see color.harmony: its 5-colour theme from that base colour, base first), rule?: the rule it was made with, name?: (default \"Theme N\"), replace?: a saved theme's name (overwrite that theme in place, e.g. after editing it; it may be renamed), color?: one colour (as colors: [colour])} save a colour theme to the local theme library (the preferences) → {name, count: themes saved}",
always,
save
),
+74 -17
View File
@@ -33,8 +33,24 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid),
cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid),
cmd!(
"shape.rectangularGrid",
"Rectangular Grid",
[],
None,
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none)} → {id}",
has_doc,
rect_grid
),
cmd!(
"shape.polarGrid",
"Polar Grid",
[],
None,
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none)} → {id}",
has_doc,
polar_grid
),
cmd!(
"path.create",
"Create Path",
@@ -58,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -351,14 +367,24 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
}
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
/// current fill when `fill`; a path of several subpaths becomes a compound path of them.
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) -> Result<Value> {
let parent = s.doc()?.target_parent()?;
let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
let fill = if fill { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
let look = s.new_art_look(fill, s.paint.stroke.clone(), s.paint.stroke_width);
let id = s.edit(label, |d, sel| {
let children = paths
.into_iter()
.map(|pd| {
let mut n = Node::path(d.alloc_id(), pd, Appearance::default());
let mut n = if pd.subpaths.len() > 1 {
let members =
pd.subpaths.into_iter().map(|sp| std::sync::Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default())));
let children = members.collect();
Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::NonZero })
} else {
Node::path(d.alloc_id(), pd, Appearance::default())
};
look.apply(d, &mut n);
std::sync::Arc::new(n)
})
@@ -371,18 +397,43 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Valu
Ok(json!({ "id": id.0 }))
}
/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to
/// ±[`shapes::GRID_SKEW_MAX`]).
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let skew = match p.get(key) {
None | Some(Value::Null) => 0.0,
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?,
};
Ok((n, skew.clamp(-shapes::GRID_SKEW_MAX, shapes::GRID_SKEW_MAX)))
}
/// A grid's on/off option `key` (`default` when absent).
fn switch(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
}
}
fn rect_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.rectangularGrid")?;
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true))
const CMD: &str = "shape.rectangularGrid";
let r = rect_of(p, CMD)?;
let paths =
shapes::rectangular_grid(r, dividers(p, "rows", "rowSkew", CMD)?, dividers(p, "columns", "columnSkew", CMD)?, switch(p, "frame", true, CMD)?);
grid_group(s, "Rectangular Grid", paths, switch(p, "fill", false, CMD)?)
}
fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.polarGrid")?;
let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
const CMD: &str = "shape.polarGrid";
let r = rect_of(p, CMD)?;
let paths = shapes::polar_grid(
r,
dividers(p, "concentric", "concentricSkew", CMD)?,
dividers(p, "radial", "radialSkew", CMD)?,
switch(p, "compound", false, CMD)?,
);
grid_group(s, "Polar Grid", paths, switch(p, "fill", false, CMD)?)
}
/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when
@@ -451,7 +502,9 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
// Lays the text out (and sizes an Auto Size frame).
super::typecmd::refresh_bounds(&mut t);
}
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?;
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
}
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
@@ -466,12 +519,16 @@ pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
}
}
/// The leading model and character alignment new type starts with (`t`'s paragraph and runs):
/// `leadingModel` and `charAlign` from `p`, else em box top to top and em box centre while the
/// The paragraph alignment, leading model and character alignment new type starts with (`t`'s
/// paragraph and runs): `justify` from `p` (else as [`new_type_para`] left it), `leadingModel`
/// and `charAlign` from `p`, else em box top to top and em box centre while the
/// interface is in Japanese (#432), else the Roman baseline for both. Noted in the journal, so a
/// replay sets the same whatever the interface.
pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut TextObject) -> Result<()> {
use vectorcraft_doc::{CharAlign, LeadingModel};
if let Some(j) = str_param(p, "justify") {
t.para.justify = super::typecmd::justify_param(j).ok_or_else(|| bad(cmd, "justify must be auto|left|center|right|justifyAll"))?;
}
let japanese = s.japanese_interface();
let leading = match p.get("leadingModel").filter(|v| !v.is_null()) {
Some(v) => match v.as_str() {
+3 -3
View File
@@ -17,17 +17,17 @@ pub fn specs() -> Vec<CommandSpec> {
"CSS Properties",
[],
None,
"{ids?: [id…] (default: the selection), units?: px|pt|mm|cm|in (default px; 1 px = 1 pt, as in SVG export), position?: false (position: absolute, left and top from the artboard's top-left), dimensions?: true (width, height), unnamed?: true (unnamed objects too, classes named after their kind; false: named objects only), rasterize?: false (art CSS can't describe as background-image: url(<class>.png), its picture, which css.export writes)} the CSS web pages style the objects with, one rule per object back to front (layers and plain groups stand for what they hold): a rectangle's or ellipse's fill as background-color or linear-/radial-gradient(), stroke as border, corners as border-radius; type's font, color, spacing and alignment; opacity, mix-blend-mode, shadows and glows (box-shadow, text-shadow), blur (filter) → {css, rules: [{id, selector, css, unsupported?: why CSS can't describe the art exactly (without rasterize it is written as its box), image?: the PNG it refers to}], skipped: n unnamed objects left out, warnings}",
"{ids?|id?: [id…] (default: the selection), units?: px|pt|mm|cm|in (default px; 1 px = 1 pt, as in SVG export), position?: false (position: absolute, left and top from the artboard's top-left), dimensions?: true (width, height), unnamed?: true (unnamed objects too, classes named after their kind; false: named objects only), rasterize?: false (art CSS can't describe as background-image: url(<class>.png), its picture, which css.export writes)} the CSS web pages style the objects with, one rule per object back to front (layers and plain groups stand for what they hold): a rectangle's or ellipse's fill as background-color or linear-/radial-gradient(), stroke as border, corners as border-radius; type's font, color, spacing and alignment; opacity, mix-blend-mode, shadows and glows (box-shadow, text-shadow), blur (filter) → {css, rules: [{id, selector, css, unsupported?: why CSS can't describe the art exactly (without rasterize it is written as its box), image?: the PNG it refers to}], skipped: n unnamed objects left out, warnings}",
has_doc,
selection
),
cmd!(query "css.generate", "Generate CSS", [], None, "{…css.selection options} css.selection of every object in the document", has_doc, generate),
cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document", has_doc, generate),
cmd!(
"css.export",
"Export CSS",
[],
None,
"{path?, scope?: selection|all (default selection; ids? picks the objects), …css.selection options} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
"{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}",
has_doc,
export
),
+4 -2
View File
@@ -14,7 +14,7 @@ use vectorcraft_pathops::BoolOp;
use super::draw2::{components, erase_outline, ids_json, replace_with_pieces};
use super::edit::selected_roots;
use super::pathops::node_path;
use super::pathops::{Beside, node_path};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -163,10 +163,12 @@ fn apply(s: &mut Session, label: &str, plan: Plan) -> Result<Value> {
}
let (ids, removed) = s.edit(label, |d: &mut Document, sel| {
let (mut all, mut removed) = (vec![], 0);
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, pieces) in plan {
removed += pieces.is_empty() as usize;
all.extend(replace_with_pieces(d, id, pieces)?);
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
}
cut.finish(d)?;
sel.set(all.iter().copied());
Ok((all, removed))
})?;
+15 -10
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Width Point",
[],
None,
"{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to edit or move, adjustAdjoining?: bool (with index: the nearest points either side change their widths in proportion)} → {index}. Without index a point already at t is replaced; a point moved onto another one's t joins it as a discontinuous point (on the side it came from), so the width steps there. Creates a uniform profile first if needed",
"{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to edit or move, adjustAdjoining?: bool (the nearest points either side change their widths in proportion, using the sampled widths for a new point)} → {index}. Without index a point already at t is replaced; a point moved onto another one's t joins it as a discontinuous point (on the side it came from), so the width steps there. Creates a uniform profile first if needed",
has_doc,
width_point_set
),
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Liquify",
[],
None,
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify? (0..100) and simplifyOn? (default true; warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids? (default: selection, else every path the brush touches)}",
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
has_doc,
liquify
),
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Puppet Warp",
[],
None,
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
has_doc,
puppet_warp
),
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Define Perspective Grid",
[],
None,
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?, leftOffset?, rightOffset?, groundOffset?: pt (planes moved along their normals), reproject?: bool} merge into the document's grid. Objects in perspective stay where they are, as in the reference app; with reproject they move with the grid (each keeps its place on its plane)",
"{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?, leftOffset?, rightOffset?, groundOffset?: pt (planes moved along their normals), reproject?: bool, …the other fields of the grid as its reply lists them} merge into the document's grid. Objects in perspective stay where they are, as in the reference app; with reproject they move with the grid (each keeps its place on its plane)",
has_doc,
grid_set
),
@@ -123,17 +123,17 @@ pub fn specs() -> Vec<CommandSpec> {
"Attach to Active Plane",
[],
None,
"{ids?, plane?: left|right|ground (default: the active plane)} project the objects onto the plane",
"{ids?|id?, plane?: left|right|ground (default: the active plane)} project the objects onto the plane",
has_doc,
attach
),
cmd!("perspective.release", "Release with Perspective", [], None, "{ids?} detach from the grid (geometry unchanged)", has_doc, release),
cmd!("perspective.release", "Release with Perspective", [], None, "{ids?|id?} detach from the grid (geometry unchanged)", has_doc, release),
cmd!(
"perspective.move",
"Move in Perspective",
[],
None,
"{ids?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it",
"{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it",
has_doc,
persp_move
),
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Draw in Perspective",
[],
None,
"{command: shape.* id, params, plane?, snap?: bool (default: Snap to Grid)} run a shape command and attach the result to the plane (snapping its corners to gridlines within a quarter cell)",
"{command: shape.* id, params, plane?, snap?: bool (default: Snap to Grid), at?: [x, y] (a point of the plane the shape lies on, as the tool gives it)} run a shape command and attach the result to the plane (snapping its corners to gridlines within a quarter cell)",
has_doc,
persp_draw
),
@@ -151,7 +151,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transform in Perspective",
[],
None,
"{ids?, matrix: [a,b,c,d,e,f] (affine map in plane coordinates, points: u along the plane, v up or away), depth?: pt to move along the plane's normal (default 0), copy?: bool, plane?} transform objects within their own planes (Perspective Selection tool handles; unattached objects attach to `plane`/the active plane first); Transform Again repeats it → {ids}",
"{ids?|id?, matrix: [a,b,c,d,e,f] (affine map in plane coordinates, points: u along the plane, v up or away), depth?: pt to move along the plane's normal (default 0), copy?: bool, plane?} transform objects within their own planes (Perspective Selection tool handles; unattached objects attach to `plane`/the active plane first); Transform Again repeats it → {ids}",
has_doc,
persp_transform
),
@@ -311,8 +311,13 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result<Value> {
}
}
None => {
let old = adjust.then(|| prof.at(t));
prof.points.retain(|q| (q.0 - t).abs() > SAME_T);
place(&mut prof.points, pt, true)
let i = place(&mut prof.points, pt, true);
if let Some((left, right)) = old {
adjust_adjoining(&mut prof.points, i, (t, left, right), pt);
}
i
}
};
st.profile = Some(prof);
+1 -1
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make Pixel Perfect",
["Object"],
None,
"{ids?} snap each object's edges to the pixel grid (odd stroke weights on half pixels) and round stroke weights",
"{ids?|id?} snap each object's edges to the pixel grid (odd stroke weights on half pixels) and round stroke weights",
has_selection,
pixel_perfect
),
+12 -6
View File
@@ -10,6 +10,7 @@ use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection,
use vectorcraft_geom::Vec2;
use super::edit::selected_roots;
use super::pathops::Beside;
use super::select::{selectable_ids, walk_selectable};
use super::*;
@@ -197,7 +198,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Guide",
[],
None,
"{index, pos: pt} put ruler guide `index` at `pos` | {dx?, dy?: pt, copy?: bool} move the selected guides (vertical ones by dx, horizontal ones by dy); copy leaves them and selects the moved copies",
"{index, pos: pt} | {dx?, dy?: pt, copy?: bool} put ruler guide `index` at `pos`, or move the selected guides (vertical ones by dx, horizontal ones by dy); copy leaves them and selects the moved copies",
guides_unlocked,
guide_move
),
@@ -225,7 +226,7 @@ pub fn specs() -> Vec<CommandSpec> {
"File Info…",
["File"],
Some("Cmd+Alt+Shift+I"),
"{title?, author?, authorTitle?, description?, keywords?: [string…]|\"a, b\" (each once), rating?: 0–5, copyrightStatus?: \"unknown\"|\"copyrighted\"|\"publicDomain\", copyrightNotice?, copyrightUrl?} set the File Info in one undo step (SVG with metadata, PDF and PNG exports carry it); no params → {title, author, authorTitle, description, keywords, rating, copyrightStatus, copyrightNotice, copyrightUrl, created, modified (ISO 8601 UTC or null; read-only), colorMode, units, artboards, objects}",
"{title?, author?, authorTitle?, description?, keywords?: [string…]|\"a, b\" (each once), rating?: 0–5, copyrightStatus?: \"unknown\"|\"copyrighted\"|\"publicDomain\", copyrightNotice?, copyrightUrl?, created?, modified?, colorMode?, units?, artboards?, objects? (the last six only as read back; ignored)} set the File Info in one undo step (SVG with metadata, PDF and PNG exports carry it); no params → {title, author, authorTitle, description, keywords, rating, copyrightStatus, copyrightNotice, copyrightUrl, created, modified (ISO 8601 UTC or null; read-only), colorMode, units, artboards, objects}",
has_doc,
super::fileinfo::file_info
),
@@ -488,8 +489,10 @@ fn release_guides(s: &mut Session, _: &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.
if v && !s.doc()?.selection.guides.is_empty() {
// Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets
// those go).
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())?;
}
Ok(json!({ "locked": v }))
@@ -504,10 +507,13 @@ fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
}
s.edit("Clear Guides", |d, _| {
d.guides.clear();
let mut gone = Beside::replacing(d, &paths);
for id in &paths {
let _ = d.remove(*id);
if d.node(*id).is_some() {
gone.put(d, *id, vec![])?;
}
}
Ok(())
gone.finish(d)
})?;
Ok(json!({ "count": n }))
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Setup",
[],
None,
"{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedFonts?: bool, highlightSubstitutedGlyphs?: bool, gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art)} change the document setup in one undo step; no params → the current setup",
"{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedFonts?: bool, highlightSubstitutedGlyphs?: bool, gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup",
has_doc,
setup
)]
+94 -24
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use kurbo::{ParamCurve, ParamCurveNearest};
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};
@@ -13,6 +13,7 @@ use vectorcraft_pathops as po;
use vectorcraft_pathops::{BoolOp, Cap, Join, SimplifyOptions};
use super::create::{add_look, add_node};
use super::pathops::{Beside, remove_each};
use super::*;
use crate::EngineError;
@@ -310,20 +311,25 @@ fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result<Node> {
}
/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above
/// it; none removes it). Returns ids.
pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
/// it; none removes it). `cut` removes the path or puts the other pieces above it. Returns ids.
pub(super) fn replace_with_pieces(d: &mut Document, cut: &mut Beside, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
let mut it = pieces.into_iter();
let Some(first) = it.next() else {
d.remove(id)?;
cut.put(d, id, vec![])?;
return Ok(vec![]);
};
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
let mut ids = vec![id];
for (k, pd) in it.enumerate() {
let mut above = vec![];
for pd in it {
let n = sibling_with(d, id, pd)?;
ids.push(d.insert(parent, idx + 1 + k, n)?);
ids.push(n.id);
above.push(n);
}
set_geometry(d, id, first)?;
if !above.is_empty() {
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
cut.put(d, id, [n].into_iter().chain(above).collect())?;
}
Ok(ids)
}
@@ -609,7 +615,10 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
let sp = fit_freehand(&pts, tol, closed);
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
let mut look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
// A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing
// gets black. A brush is its stroke.
let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() };
let mut look = s.new_art_look(fill, stroke, s.paint.stroke_width.max(0.1));
if let Some(st) = look.appearance.stroke_mut() {
st.pressure = pressure;
}
@@ -733,7 +742,10 @@ fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
if ai >= sp.anchors.len() {
return Err(EngineError::Other("no such anchor".into()));
}
remove_anchors_of(d, sel, id, [(si, ai)])
let mut emptied = Beside::replacing(d, &[id]);
let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?;
emptied.finish(d)?;
Ok(removed)
})?;
Ok(json!({ "removedObject": removed }))
}
@@ -752,18 +764,26 @@ fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
}
let removed = s.edit("Remove Anchor Points", |d, sel| {
let mut removed = 0;
let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, anchors) in jobs {
removed += usize::from(remove_anchors_of(d, sel, id, anchors)?);
removed += usize::from(remove_anchors_of(d, sel, &mut emptied, id, anchors)?);
}
emptied.finish(d)?;
Ok(removed)
})?;
Ok(json!({ "removedObjects": removed }))
}
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
/// each one; a subpath left with one anchor goes, and the path with its last subpath. True when
/// the path went.
fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors: impl IntoIterator<Item = (usize, usize)>) -> Result<bool> {
/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed
/// through `emptied`. True when the path went.
fn remove_anchors_of(
d: &mut Document,
sel: &mut Selection,
emptied: &mut Beside,
id: NodeId,
anchors: impl IntoIterator<Item = (usize, usize)>,
) -> Result<bool> {
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
// The last first, so the indexes still to come stay valid.
anchors.sort_unstable_by(|a, b| b.cmp(a));
@@ -777,7 +797,7 @@ fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors:
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
let gone = path.is_empty();
if gone {
d.remove(id)?;
emptied.put(d, id, vec![])?;
sel.remove(id);
} else {
sel.anchors.remove(&id);
@@ -920,9 +940,11 @@ fn knife(s: &mut Session, p: &Value) -> Result<Value> {
}
let ids = s.edit("Knife", |d, sel| {
let mut all = vec![];
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, pieces) in plan {
all.extend(replace_with_pieces(d, id, pieces)?);
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
}
cut.finish(d)?;
sel.set(all.iter().copied());
Ok(all)
})?;
@@ -960,9 +982,11 @@ fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec<PathData>)>)
}
let ids = s.edit(label, |d, sel| {
let mut all = vec![];
let mut cut = Beside::replacing(d, &plan.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, pieces) in plan {
all.extend(replace_with_pieces(d, id, pieces)?);
all.extend(replace_with_pieces(d, &mut cut, id, pieces)?);
}
cut.finish(d)?;
sel.set(all.iter().copied());
Ok(all)
})?;
@@ -1027,17 +1051,46 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio
open.anchors.push(open.anchors[0]);
m.push(false);
open.closed = false;
let mut res = smooth_open(&open.anchors, &m, tol);
let mut res = smooth_open(&open.anchors, &m, near, tol);
let l = res.pop()?;
if let Some(r0) = res.first_mut() {
r0.h_in = l.h_in;
}
return Some(SubPath::new(res, true));
}
Some(SubPath::new(smooth_open(&sp.anchors, &marked, tol), false))
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false))
}
fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
/// enters the reach: the part outside, then the part in reach. `None` when no more than a sliver
/// is outside.
fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez, CubicBez)> {
const STEPS: u32 = 32;
let step = 1.0 / f64::from(STEPS);
// Back from the end in reach to the first sample outside, then halving to the crossing.
let outside = (1..=STEPS).map(|i| 1.0 - f64::from(i) * step).find(|t| !near(c.eval(*t)))?;
let (mut lo, mut hi) = (outside, outside + step);
for _ in 0..20 {
let mid = (lo + hi) / 2.0;
if near(c.eval(mid)) {
hi = mid;
} else {
lo = mid;
}
}
// A straight segment's parts stay straight, their handles on their ends.
let straight = c.p1 == c.p0 && c.p2 == c.p3;
let part = |range| {
let s = c.subsegment(range);
if straight { CubicBez::new(s.p0, s.p0, s.p3, s.p3) } else { s }
};
(hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0)))
}
/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments
/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under
/// it, and the rest of a long segment next to it stays as it was (#1512).
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec<Anchor> {
let n = anchors.len();
let mut runs = vec![];
let mut i = 0;
@@ -1058,8 +1111,27 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
if hi - lo < 2 {
continue;
}
let new = refit_run(&out[lo..=hi], tol);
out.splice(lo..=hi, new);
let mut piece = out[lo..=hi].to_vec();
// The anchors outside the reach, kept with the part of their segment that is outside.
let (mut head, mut tail) = (None, None);
if hi > b
&& let [.., inner, outer] = piece.as_mut_slice()
&& let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_in, inner.h_out, inner.p), near)
{
inner.h_out = reach.p2;
tail = Some(Anchor { h_in: far.p1, ..*outer });
*outer = Anchor::with_handles(far.p3, reach.p1, far.p2);
}
if lo < a
&& let [outer, inner, ..] = piece.as_mut_slice()
&& let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_out, inner.h_in, inner.p), near)
{
inner.h_in = reach.p2;
head = Some(Anchor { h_out: far.p1, ..*outer });
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
}
let new = refit_run(&piece, tol);
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
}
out
}
@@ -1256,9 +1328,7 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
s.edit("Blob Brush", |d, sel| {
*path_mut(d, keep)? = merged;
for id in others {
d.remove(id)?;
}
remove_each(d, &others)?;
sel.set([keep]);
Ok(())
})?;
+71 -27
View File
@@ -18,15 +18,15 @@ pub fn specs() -> Vec<CommandSpec> {
"New…",
["File"],
None,
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
always,
super::newdoc::file_new
),
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree, artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group, Path, Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
cmd!(
"document.setUnits",
@@ -128,11 +128,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear",
["Edit"],
Some("Delete"),
"{ids?} delete the objects `ids`, else the selection (direct-selected anchors, else objects, else ruler guides)",
"{ids?} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides)",
has_selection_or_guides,
clear
),
cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate),
cmd!(
"edit.duplicate",
"Duplicate",
["Edit"],
None,
"{dx?, dy?} duplicate the selection in place (offset optional)",
has_selection,
duplicate
),
]
}
@@ -151,20 +159,22 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
let opts = slice_opts(p, "document.node")?;
let mut opts = slice_opts(p, "document.node")?;
let summary = bool_or(p, "summary", false);
if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) {
// The full object JSON is never truncated: a slice without `summary` would
// otherwise silently return the whole subtree.
return Err(bad("document.node", "`depth` and `childLimit` slice the summary: pass `summary: true`"));
}
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
if summary { Ok(inspect::node_summary_opts(n, opts)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
let doc = &s.doc()?.doc;
let n = doc.node(id).ok_or(EngineError::NoNode(id))?;
opts.compound_member = doc.parent_of(id).and_then(|p| doc.node(p)).is_some_and(|p| matches!(p.kind, vectorcraft_doc::NodeKind::Compound { .. }));
if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
}
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")? })
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false })
}
/// An optional count: absent (or null) is `None`; a present value that is not a
@@ -249,16 +259,23 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow
// the document.
/// What is still in progress (typing, a drag) is a step of its own: keep it before the history
/// moves, so Undo takes back only that and Redo brings it back, instead of dropping it and undoing
/// the step before. As any new edit it ends the steps to redo, so Redo never swaps the document
/// under an interaction that would then preview on, or cancel to, the document from before (#1719).
/// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the
/// document.
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
let typing = typing_in_progress(s);
s.commit_interaction()?;
if typing {
s.set_tool_option("endTyping", &Value::Bool(true));
}
Ok(())
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
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();
@@ -276,6 +293,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
}
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
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();
@@ -358,7 +376,7 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
// Direct-selected anchors or segments: remove those components instead of whole objects.
// Direct-selected anchors or segments: remove those, and any objects selected whole with them.
if explicit.is_none() && !s.doc()?.selection.anchors.is_empty() {
return super::path::delete_anchors(s, p);
}
@@ -382,9 +400,7 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
return ok();
}
s.edit("Clear", |d, sel| {
for id in &ids {
d.remove(*id)?;
}
d.remove_many(&ids)?;
if with_guides {
let gone = sel.guides.clone();
d.retain_guides(sel, |i, _| !gone.contains(&i));
@@ -558,20 +574,48 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// Duplicate `roots` directly above each original, transform the copies, select them.
/// Duplicate `roots` directly above each original, transform the copies, select them
/// ([`copy_above`]).
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
let mut out = vec![];
for id in roots {
let Some((par, idx, _)) = d.position(*id) else { continue };
let Some(n) = d.node(*id).cloned() else { continue };
let mut c = d.reid(&n);
c.transform(xf, true);
out.push(d.insert(par, idx + 1, c)?);
}
let out = copy_above(d, roots, |_, _, c| c.transform(xf, true))?;
sel.set(out.iter().copied());
Ok(out)
}
/// Copy `roots` directly above each original and return the copies' ids. `change` gets the
/// document, each original and its copy before the copy goes in, and must leave the document's
/// tree as it is. The copies take new ids in the order of `roots`. Roots listed twice, or inside
/// another root, are copied one at a time: each copy is made from the document as the copies
/// before it left it.
pub(crate) fn copy_above(d: &mut Document, roots: &[NodeId], mut change: impl FnMut(&mut Document, &Node, &mut Node)) -> Result<Vec<NodeId>> {
let paths = d.index_paths(roots.iter().copied());
let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect();
found.sort_unstable();
let out = if found.windows(2).all(|w| !w[1].starts_with(w[0])) {
let mut copies = Vec::with_capacity(roots.len());
for id in roots {
let Some(n) = paths.get(id).and_then(|p| d.node_at(p)).cloned() else { continue };
let mut c = d.reid(&n);
change(d, &n, &mut c);
copies.push((*id, vec![c]));
}
let out: Vec<NodeId> = copies.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect();
d.insert_beside(copies, false)?;
out
} else {
let mut out = vec![];
for id in roots {
let Some((par, idx, _)) = d.position(*id) else { continue };
let Some(n) = d.node(*id).cloned() else { continue };
let mut c = d.reid(&n);
change(d, &n, &mut c);
out.push(d.insert(par, idx + 1, c)?);
}
out
};
Ok(out)
}
#[allow(dead_code)]
pub(crate) fn arc_nodes(v: Vec<Node>) -> Vec<Arc<Node>> {
v.into_iter().map(Arc::new).collect()
+137 -23
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
has_doc,
apply
),
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effects",
[],
None,
"{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin.<plug-in id>, menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
"{ids?|id?: object ids (default: the selection), target?: \"object\"|\"contents\"} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin.<plug-in id>, menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
always,
list
),
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Effect",
[],
None,
"{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?: [..]} → {ids}",
"{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
has_doc,
remove
),
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effect Options",
[],
None,
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?: [..]} → {ids}",
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} → {ids}",
has_doc,
set_params
),
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Appearance",
["Object"],
None,
"{ids?: [..], 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), 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}",
can_expand,
expand_appearance
),
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Effect",
[],
None,
"{index: int, item?: appearance item index|null (as effect.remove), ids?: [..]} insert a copy of the effect right after it → {ids}",
"{index: int, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\"} insert a copy of the effect right after it → {ids}",
has_doc,
duplicate
),
@@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Effect",
[],
None,
"{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?: [..]} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
"{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\"} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}",
has_doc,
move_effect
),
@@ -119,13 +119,18 @@ fn edit_effects(
}
fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> {
let count = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects())).filter(|e| e.id == effects::REVOLVE).count();
if count > 1 {
let all = || n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects()));
if all().filter(|e| e.id == effects::REVOLVE).count() > 1 {
return Err(bad(cmd, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
}
if all().filter(|e| effects::is_three_d(&e.id)).count() > 1 {
return Err(bad(cmd, ONE_3D));
}
Ok(())
}
const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first.";
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.apply";
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
@@ -149,12 +154,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
}
let item = item_target(s, p, C)?;
if id == effects::REVOLVE {
for id in appearance_targets(s, p)? {
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
if effects::has_revolve(n) {
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
if effects::is_three_d(id) {
for target in appearance_targets(s, p)? {
let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?;
if id == effects::REVOLVE {
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
if effects::has_revolve(n) {
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
}
} else {
effects::validate_solid(n, id, &effect.params).map_err(|e| bad(C, e))?;
}
if effects::has_revolve(n) || effects::has_solid(n) {
return Err(bad(C, ONE_3D));
}
}
}
@@ -268,6 +280,20 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": item.flatten() }))
}
/// The ids of the effects `effect.setParams` with `p` edits, for the check of their parameters
/// (none when `p` doesn't say which: running it reports why).
pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
const C: &str = "effect.setParams";
let (Ok(index), Ok(item), Ok(ids), Ok(st)) = (index_param(p, "index", C), item_target(s, p, C), appearance_targets(s, p), s.doc()) else {
return vec![];
};
let effect = |id: &NodeId| {
let ap = &st.doc.node(*id)?.appearance;
Some(ap.effects_at(item.effects_item(ap, C).ok()?)?.get(index)?.id.clone())
};
ids.iter().filter_map(effect).collect()
}
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;
@@ -281,26 +307,31 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
let target = item.effects_item(&n.appearance, C)?;
if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index))
&& e.id == effects::REVOLVE
&& effects::is_three_d(&e.id)
&& visible != Some(false)
{
let mut prospective = effects::merged_params(&e.id, &e.params);
if let (Some(cur), Some(new)) = (prospective.as_object_mut(), params.as_object()) {
cur.extend(new.clone());
if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(&e.id, &params)) {
cur.extend(new);
}
if e.id == effects::REVOLVE {
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
} else {
effects::validate_solid(n, &e.id, &prospective).map_err(|e| bad(C, e))?;
}
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
}
}
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
let Some(e) = fx.get_mut(index) else { return false };
if let (Value::Object(new), cur) = (&params, &mut e.params) {
if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, &params), &mut e.params) {
if !cur.is_object() {
*cur = effects::merged_params(&e.id, &Value::Null);
}
// Earlier parameters (a Perspective strength, a light by azimuth and elevation) take
// their current form first, so the new values replace them and the rest is kept.
*cur = effects::upgrade_params(&e.id, cur);
if let Value::Object(m) = cur {
for (k, v) in new {
m.insert(k.clone(), v.clone());
}
m.extend(new);
}
}
if let Some(v) = visible {
@@ -533,6 +564,89 @@ mod tests {
assert_eq!(effects_for_test(&s, id), before);
}
/// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited,
/// exported, expanded and undone; one 3D effect per object.
#[test]
fn solid_effects_apply_save_edit_export_expand_and_undo() {
for effect in effects::SOLIDS {
let (mut s, id) = session_with_rect();
let source = node(&s, id).path_data().unwrap().clone();
s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap();
let art = effects::solid_art(&node(&s, id)).unwrap();
assert_eq!(node(&s, id).path_data(), Some(&source), "{effect}: the source stays editable");
let doc = &s.doc().unwrap().doc;
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art, "{effect}: reopens the same");
// One 3D effect per object, whichever kind.
for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] {
assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}");
}
s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap();
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art");
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err());
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string());
assert!(svg.matches("<path").count() >= 2, "{effect}: exported as vector faces");
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}");
assert!(!effects::has_solid(&node(&s, id)));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle");
}
// Type has no outline to extrude.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let t = s.execute("text.create", &json!({"x": 50, "y": 50, "text": "3D"})).unwrap();
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [t["id"]], "item": null})).unwrap_err();
assert!(err.to_string().contains("requires a path"), "{err}");
}
/// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is
/// stored as the angle that shows the same view, and a later angle replaces an earlier strength.
#[test]
fn an_earlier_perspective_strength_is_set_as_its_angle() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null, "params": {"perspective": 60}})).unwrap();
let params = |s: &Session| node(s, id).appearance.effects[0].params.clone();
let angle_of = |pct: f64| effects::merged_params(effects::EXTRUDE, &json!({"perspective": pct}))["perspectiveAngle"].as_f64().unwrap();
let angle = angle_of(60.0);
assert!((angle - 30.12).abs() < 0.01, "{angle}");
assert!(
params(&s).get("perspective").is_none() && (params(&s)["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12,
"{}",
params(&s)
);
s.execute("effect.setParams", &json!({"index": 0, "params": {"perspective": 100}, "ids": [id.0], "item": null})).unwrap();
assert!((params(&s)["perspectiveAngle"].as_f64().unwrap() - angle_of(100.0)).abs() < 1e-12);
// A file of then: the strength stored; setting an angle replaces it.
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params =
json!({"depth": 50, "perspective": 60});
s.execute("effect.setParams", &json!({"index": 0, "params": {"perspectiveAngle": 120}, "ids": [id.0], "item": null})).unwrap();
assert_eq!(params(&s), json!({"depth": 50, "perspectiveAngle": 120}));
}
/// Scripts from before Rotation and Height give the light by `lightAzimuth` and
/// `lightElevation`: stored as the same light; a later Height keeps that light's Rotation.
#[test]
fn an_earlier_light_direction_is_set_as_rotation_and_height() {
let (mut s, id) = session_with_rect();
s.execute(
"effect.apply",
&json!({"effect": effects::ROTATE, "ids": [id.0], "item": null, "params": {"lightAzimuth": 180, "lightElevation": 0}}),
)
.unwrap();
let params = |s: &Session| node(s, id).appearance.effects[0].params.clone();
// Straight from behind the object, level with it.
assert!(params(&s).get("lightAzimuth").is_none() && params(&s).get("lightElevation").is_none(), "{}", params(&s));
assert_eq!((params(&s)["lightHeight"].as_f64().unwrap().round(), &params(&s)["lightBehind"]), (90.0, &json!(true)));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params =
json!({"lightAzimuth": 90, "lightElevation": 0});
s.execute("effect.setParams", &json!({"index": 0, "params": {"lightHeight": 60}, "ids": [id.0], "item": null})).unwrap();
assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false}));
}
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
effects::revolve_art(&node(s, id)).unwrap()
}
+11 -3
View File
@@ -1,5 +1,5 @@
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
//! (Fill) into plain art in one undo step. Placed documents (Object) become editable copies of the
//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
has_selection,
expand
),
@@ -106,6 +106,8 @@ struct Expandable {
blends: bool,
/// Placed documents (Object expands them into the art they show).
placed: bool,
/// Repeats (Object expands them into groups of their instances).
repeats: bool,
}
impl Expandable {
@@ -120,6 +122,7 @@ impl Expandable {
e.envelope |= matches!(c.kind, NodeKind::Envelope { .. });
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
e.fill |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
@@ -131,7 +134,7 @@ impl Expandable {
}
fn object(&self) -> bool {
self.text || self.live || self.effects || self.envelope || self.blends || self.placed
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats
}
}
@@ -164,6 +167,11 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| super::live::expand_blends(d, &roots).map(|_| ()))?;
}
// Repeats too: their instances are copies of art that expands below.
if object && e.repeats {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| super::patterncmds::expand_repeats(d, &roots).map(|_| ()))?;
}
if object && e.effects {
let _ = run_raw(s, "effect.expandAppearance", &json!({}));
}
+5
View File
@@ -32,6 +32,7 @@ pub(super) fn import(name: &str, bytes: &[u8]) -> Result<Imported> {
Ok(d) => {
let mut b = Builder { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k: 72.0 / d.dpi, layer: 0 };
b.document(&d)?;
b.doc.limit_depth_noting(&mut b.warnings);
let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::<Vec<_>>();
warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}.")));
Ok(Imported { doc: b.doc, warnings, preview_only: false })
@@ -488,6 +489,10 @@ impl Builder {
};
if let Some(f) = t.frame {
obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, (f.x1 - f.x0) * k, (f.y1 - f.y0) * k)) };
// Affinity shows a line in a frame shorter than it (#1423); here none of it would draw.
if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) {
self.warn("text frames too short for their first line (made taller so the line shows)");
}
}
obj
}
+22 -19
View File
@@ -13,14 +13,22 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
// What an error rolls back besides the documents: session-level state a step may change
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
// A drag or typing still in progress is a step of its own, as before Undo: the batch's steps
// would otherwise run inside it and take its baseline as theirs (#1697).
if s.active().is_some() {
super::super::edit::settle_interaction(s)?;
}
// The documents as they are now, whole: what a failed step brings back. Their art, history,
// file, format and saved state share their data with the open ones until a step changes them.
let start = s.docs.clone();
let active = s.active;
s.batch_stash = Some(vec![]);
let r = run_steps(s, &label, artboard, &mut cmds);
let stash = s.batch_stash.take().unwrap_or_default();
let results = match r {
Ok(results) => results,
Err(e) => {
roll_back(s, &start, stash);
roll_back(s, start, &stash);
s.active = active;
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved;
s.pending_paint = None;
@@ -81,22 +89,17 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
Ok(results)
}
/// The documents as they were before the batch (`start`: their uids in order): the ones it opened
/// close, the ones it closed or replaced (`stash`) come back, and each one's interaction is undone.
fn roll_back(s: &mut Session, start: &[u64], mut stash: Vec<DocState>) {
let mut open = std::mem::take(&mut s.docs);
for &uid in start {
// The newest revision the document reached, so the restored one redraws.
let newest = open.iter().chain(&stash).filter(|d| d.uid == uid).map(|d| d.revision).max().unwrap_or_default();
// Its state before the batch: the first one a step closed or replaced, else the open one.
let st = match stash.iter().position(|d| d.uid == uid) {
Some(i) => Some(stash.remove(i)),
None => open.iter().position(|d| d.uid == uid).map(|i| open.remove(i)),
};
if let Some(mut st) = st {
st.undo_interaction();
st.revision = newest.saturating_add(1);
s.docs.push(st);
}
/// The documents as they were before the batch (`start`), whatever the steps did to them: opened
/// 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.
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
let open = std::mem::take(&mut s.docs);
s.docs = start;
for st in &mut s.docs {
// 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);
}
}
+9
View File
@@ -175,6 +175,8 @@ struct RasterOptions {
layers: Option<bool>,
max_editability: Option<bool>,
hidden_layers: Option<bool>,
/// Appearance of Black › Printing / Exporting (`prefs.blackOutput`); agents can set it here too.
rich_black: Option<bool>,
}
impl RasterOptions {
@@ -239,6 +241,7 @@ impl RasterOptions {
hidden_layers: self.hidden_layers.unwrap_or(false),
embed_icc,
},
rich_black: self.rich_black.unwrap_or(false),
})
}
}
@@ -382,7 +385,13 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
/// [`encode_all`] of `doc` as it is.
fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let f = super::writable(C, Some(format), None)?;
let failed = vectorcraft_render::decode_failures();
let mut enc = encode_files(doc, f, p)?;
// 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());
}
// 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).
if (f.raster || matches!(f.id, "eps" | "emf" | "wmf"))
+1 -1
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export EPS",
[],
None,
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
"{path?, level?: 2|3 (default; PostScript language level: 3 writes smooth gradients and masks transparent image pixels out, 2 steps gradients and writes those pixels white), previewFormat?: none|tiffBw|tiffColor (default: a TIFF preview other apps show), transparentPreview?: true (else on white), overprints?: preserve (default)|discard, flattenerPreset?: \"medium\" (default; high, low or a saved preset: flattener.presets.list), flattener?: {the options of object.flattenTransparency over the preset}, embedFonts?: true (type is always written as outlines, so no fonts are needed), includeLinkedFiles?: false (the embedded document keeps linked images whole, not just their previews), thumbnails?: true (a PNG thumbnail in the file), cmykPostScript?: true (RGB documents written in CMYK), compatibleGradients?: false (stepped gradients at level 3 too), selectedOnly?: false, useArtboards?: true (one file per chosen artboard, artboards?/range?, {stem}_{artboard}.eps, the bounding box the artboard) | false (default: one file, the bounding box the visible art, y up from the first artboard's bottom-left corner), format?: \"eps\", artboard?: i | artboards?: [i…] | range?: \"1-3, 5\" (with useArtboards: the artboards written)} → {path, bytes, warnings, files?}; no path → {dataBase64, …}. Transparency is flattened with the preset (EPS has none); the native document rides along in comments, for reopening the file as it was. Same as document.export {format: eps}",
has_doc,
export_eps
)]
+33 -19
View File
@@ -5,20 +5,23 @@ use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, SymbolBoxes};
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, writable, write_encoded, write_or_return};
use super::{
ARTBOARD_PARAMS, Format, default_name, encode, 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> {
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
let expanded = expand_presets(s, "document.serialize", p)?;
let p = &*expanded;
let doc = &*source(s, f, p, "document.serialize")?;
let enc = encode_all(doc, f.id, p)?;
let p = with_black_output(s, &expanded);
let doc = &*source(s, f, &p, "document.serialize")?;
let enc = encode_all(doc, f.id, &p)?;
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
let (main, linked) = files.split_at(enc.files.len());
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
@@ -50,9 +53,9 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let f = writable("document.export", str_param(p, "format"), path)?;
let expanded = expand_presets(s, "document.export", p)?;
let p = &*expanded;
let doc = &*source(s, f, p, "document.export")?;
let enc = encode_all(doc, f.id, p)?;
let p = with_black_output(s, &expanded);
let doc = &*source(s, f, &p, "document.export")?;
let enc = encode_all(doc, f.id, &p)?;
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
}
@@ -92,21 +95,29 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
/// out, at any depth of layers and sublayers, and objects that reach it stay whole (the file's
/// bounds clip them), as DXF's crop leaves them. A clipping layer keeps every object, its clipping
/// path among them. An artboard's file then holds its own art, not a whole document of artboards.
/// bounds clip them), as DXF's crop leaves them. Symbol instances are measured by their art, and
/// the box of one whose art holds brushed strokes grows by as far as their brush art can paint
/// ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol instance also stays when what
/// the canvas paints for it reaches `rect` ([`vectorcraft_render::painted_bounds`]: the effects in
/// the symbols' art and its own effects). A clipping layer keeps every object, its clipping path
/// among them. An artboard's file then holds its own art, not a whole document of artboards.
pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document {
fn kept(n: &Arc<Node>, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
fn kept(n: &Arc<Node>, doc: &Document, symbols: &SymbolBoxes, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
if matches!(n.kind, NodeKind::Layer { clip: false, .. }) {
let mut layer = Node::clone(n);
let children = layer.children_mut()?;
*children = children.iter().filter_map(|c| kept(c, rect)).collect();
*children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect();
return Some(Arc::new(layer));
}
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
n.visual_bounds().is_none_or(over).then(|| n.clone())
let mut holds_instance = false;
n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over);
(n.visual_bounds_with(symbols).is_none_or(over) || painted()).then(|| n.clone())
}
let symbols = doc.symbol_boxes().with_brush_art();
let mut d = doc.clone();
d.layers = doc.layers.iter().filter_map(|l| kept(l, rect)).collect();
d.layers = doc.layers.iter().filter_map(|l| kept(l, doc, &symbols, rect)).collect();
d
}
@@ -152,10 +163,12 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
}
/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard
/// named `name`: their visual bounds (`None` when they have none).
/// named `name`: their visual bounds, symbol instances measured by their art (`None` when they
/// have none).
pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> {
let nodes: Vec<Arc<Node>> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))?;
let symbols = doc.symbol_boxes();
let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))?;
let mut d = single_artboard(doc, bounds, name);
let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
@@ -175,11 +188,11 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path");
let f = writable(C, str_param(p, "format"), path)?;
let expanded = expand_presets(s, C, p)?;
let p = &*expanded;
let p = with_black_output(s, &expanded);
let (d, bounds) = selection(s, C)?;
// The encoder's warnings (options accepted but not applied, features approximated or left
// out), as document.export reports them.
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(p))?;
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
}
@@ -201,7 +214,8 @@ pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("no artboard {artboard} (0-based; the document has {})", doc.artboards.len())));
}
let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" };
let bytes = encode(doc, "png", &json!({ "ppi": ppi, "background": background, "artboard": artboard }))?;
let opts = with_black_output(s, &json!({ "ppi": ppi, "background": background, "artboard": artboard }));
let bytes = encode(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) }))
+79 -30
View File
@@ -9,6 +9,7 @@ use vectorcraft_geom::Affine;
use super::super::*;
use super::pdfimport::LoadOptions;
use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, psdread, read_file};
use crate::cmd::links::Resolved;
use crate::{EngineError, Prefs};
/// A file read into a document, with its format and non-fatal import notes.
@@ -138,7 +139,8 @@ fn restore_or_import(
/// (with a warning for each that can't be used).
pub(crate) fn native_file(bytes: &[u8]) -> Result<(Document, vectorcraft_format::FileInfo, Vec<String>)> {
let f = vectorcraft_format::load_file(bytes).map_err(err)?;
let warnings = super::native::install_profiles(f.profiles);
let mut warnings = super::native::install_profiles(f.profiles);
warnings.extend(f.depth_notes);
Ok((f.doc, f.info, warnings))
}
@@ -158,7 +160,7 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning
}
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined);
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data);
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
if !read {
return (doc, warnings);
@@ -192,7 +194,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let title = file_name(name);
let mut converted = false;
let mut preview_only = false;
let (mut doc, warnings, restored) = match format.id {
let (mut doc, mut warnings, restored) = match format.id {
// EPS and PostScript .ai: the document our EPS files carry, else the PostScript read.
_ if format.id == "eps" || vectorcraft_pdf::is_postscript(bytes) => {
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
@@ -221,8 +223,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || match super::pdfimport::import(bytes, opts) {
Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)),
restore_or_import(editing, || match super::pdfimport::import(bytes, opts, format.id != "pdf") {
Ok(r) => {
// Pages of text alone: the editing copy alone where it reads, as for a file
// saved without its PDF part (#1387: else the page, which is the art).
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings)))
}
// Saved without its PDF part: the editing copy is all the file has.
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
@@ -258,6 +265,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
doc.title = title;
}
warnings.extend(doc.images.values().filter_map(|b| too_large_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only })
}
@@ -265,12 +273,17 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
/// own): rasters stay linked, SVG files become art, missing ones placeholders (see
/// [`vectorcraft_svg::ImportOptions`]).
pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> {
// The importer reads on a thread of its own (for its stack): what may be read is what may be
// read here.
let scope = crate::file_access::ScopeHandle::capture();
let read = |path: &str| {
// Only files (not folders or devices) are read.
file_stamp(path)?;
let bytes = read_file(path).ok()?;
let link = crate::cmd::links::link_info(path, &bytes);
Some((bytes, link))
scope.clone().run(|| {
// Only files (not folders or devices) are read.
file_stamp(path)?;
let bytes = read_file(path).ok()?;
let link = crate::cmd::links::link_info(path, &bytes);
Some((bytes, link))
})
};
vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err)
}
@@ -286,47 +299,67 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
open_read(s, read_for_open(name, bytes, p)?, path)
let update_links = s.prefs.update_links == "automatically";
open_read(s, read_for_open(name, bytes, path, p, update_links)?)
}
/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]).
pub struct ReadFile {
loaded: Loaded,
opts: LoadOptions,
/// Where the file was read from.
path: Option<String>,
/// What became of its linked files.
links: Resolved,
}
/// The first half of [`open_bytes_with`]: read and decode the file, which needs no session (an
/// app may do it on another thread) → what [`open_read`] opens.
pub fn read_for_open(name: &str, bytes: &[u8], p: &Value) -> Result<ReadFile> {
#[cfg(test)]
impl ReadFile {
/// The document as it was read, before it opens.
pub(crate) fn document(&self) -> &Document {
&self.loaded.doc
}
}
/// The first half of [`open_bytes_with`], which needs no session (an app may do it on another
/// thread): read and decode the file, read its linked files and lay out its type → what
/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from,
/// and modified ones are read again when `update_links` (Preferences › File Handling › Update
/// Links set to Automatically; see [`crate::cmd::links::resolve`]).
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool) -> Result<ReadFile> {
let opts = LoadOptions::from_params("document.open", p)?;
Ok(ReadFile { loaded: load_with(name, bytes, &opts)?, opts })
let mut loaded = load_with(name, bytes, &opts)?;
let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links);
Session::refresh_text_bounds(&mut loaded.doc);
Ok(ReadFile { loaded, opts, path, links })
}
/// The second half of [`open_bytes_with`]: open a file [`read_for_open`] read as the new active
/// document (`path`: where it was read from) → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile, path: Option<String>) -> Result<Value> {
open_loaded(s, file.loaded, path, &file.opts, false)
/// document → `document.open`'s result.
pub fn open_read(s: &mut Session, file: ReadFile) -> Result<Value> {
open_loaded(s, file.loaded, file.links, file.path, &file.opts, false)
}
/// File → New from Template: any readable file (at `path`, if it is one: its links are looked for
/// from there) as a new untitled document.
pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&str>) -> Result<Value> {
let loaded = load(name, bytes)?;
let mut loaded = load(name, bytes)?;
if loaded.preview_only {
return Err(err(
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
));
}
open_loaded(s, loaded, path.map(str::to_string), &LoadOptions::default(), true)
let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically");
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true)
}
/// Make a loaded file the new active document; `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
/// Make a loaded file the new active document; `links` is what became of its linked files, and
/// `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs);
// What reading the file left out of the document, if anything.
let losses: Vec<String> = warnings.iter().filter(|w| is_loss(w)).cloned().collect();
let source_path = path.clone().filter(|_| !losses.is_empty());
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
// A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled
// document.
let template = as_template || doc.template || matches!(format.id, "ait" | "template");
@@ -376,23 +409,39 @@ pub(crate) fn source<'a>(p: &'a Value, cmd: &str) -> Result<Source<'a>> {
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let src = source(p, "document.open")?;
open_bytes_with(s, src.name, &src.bytes, src.path.map(str::to_string), p)
// A relative path is the file in the working directory: its folder is where relative links
// are found (a bare file name has no folder of its own).
let path = src.path.map(super::absolute_path);
open_bytes_with(s, path.as_deref().unwrap_or(src.name), &src.bytes, path.clone(), p)
}
pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
let src = source(p, "file.newFromTemplate")?;
open_template(s, src.name, &src.bytes, src.path)
let path = src.path.map(super::absolute_path);
open_template(s, path.as_deref().unwrap_or(src.name), &src.bytes, path.as_deref())
}
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
/// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
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.
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
use image::ImageDecoder as _;
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"))
}
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop
/// documents, whose merged image is read ([`psdread`]).
/// documents, whose merged image is read ([`psdread`]). Grayscale TIFFs with an alpha channel or
/// other extra channels, which the decoder can't read either, become RGBA too.
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let ppi = super::ppi::resolution(bytes);
if psdread::is_psd(bytes) {
@@ -410,11 +459,11 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let (width, height) = reader.into_dimensions().map_err(err)?;
return stored(bytes.to_vec(), f.mime, (width, height), ppi);
}
let cmyka = || {
let extra_samples = || {
let cms = vectorcraft_color::cms::active();
vectorcraft_doc::cmyk::cmyka_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false))
};
let img = match (f.id == "tiff").then(cmyka).flatten() {
let img = match (f.id == "tiff").then(extra_samples).flatten() {
Some(img) => img,
None => reader.decode().map_err(err)?.to_rgba8(),
};
+34 -6
View File
@@ -28,6 +28,7 @@ pub mod eps;
mod export;
pub(crate) mod imagemap;
mod load;
pub(crate) use load::{MAX_RASTER_ALLOC, too_large_note};
mod metafile;
mod native;
pub mod pdf;
@@ -86,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
@@ -113,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, bmp: colorModel?: rgb|gray, depth?: 24 (1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), tga: depth?: 24 (16 one-bit alpha, 24, 32 with alpha), psd: colorModel?: rgb|cmyk|gray, layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true, at most 30000 pixels a side; formats without alpha are flattened on white} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -158,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
"PDF Info",
[],
None,
"{path} or {name?, dataBase64}, password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box) → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; an encrypted PDF without its password → {pages: 0, needsPassword: true}",
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; an encrypted PDF without its password → {pages: 0, needsPassword: true}",
always,
pdfimport::pdf_info
),
@@ -167,7 +168,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} 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} (pixels); no path → {dataBase64, bytes, width, height}",
has_doc,
export::export_for_office
),
@@ -673,7 +674,7 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel), Libraries panel libraries, flattener, PDF and print
/// presets (imported).
/// presets (imported), scripts (checked without running them) and plug-ins (installed).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS))
.chain(format_filters())
@@ -682,6 +683,8 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Shortcut sets", super::shortcutset::EXTS)))
.chain(std::iter::once(("Scripts", super::scripts::EXTS)))
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
@@ -765,6 +768,20 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// The first `max` bytes of the file at `path` (all of it when it is shorter). Anything but a
/// regular file (a folder, a pipe, a device) is refused, also through a link.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>> {
use std::io::Read;
may_read(path)?;
if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
return Err(EngineError::Other(format!("{path}: not a file")));
}
let mut out = vec![];
std::fs::File::open(path).and_then(|f| f.take(max as u64).read_to_end(&mut out)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(out)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
may_write(path)?;
@@ -793,7 +810,7 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
may_write(path)?;
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?;
let written = f.write_all(bytes).and_then(|()| f.sync_all());
let written = f.write_all(bytes).and_then(|()| vectorcraft_format::sync_file(&f));
drop(f);
written.map_err(|e| {
// Best effort: the partly written file is all there is to clean up.
@@ -943,6 +960,11 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn read_file_head(path: &str, _: usize) -> Result<Vec<u8>> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> {
Err(no_fs(path))
@@ -1031,6 +1053,12 @@ pub(crate) fn merge(mut a: Value, b: Value) -> Value {
a
}
/// Appearance of Black › Printing / Exporting (#394): Output All Blacks as Rich Black makes RGB
/// raster exports draw pure CMYK black as RGB black (`richBlack` on the encoder params).
pub(crate) fn with_black_output(s: &Session, p: &Value) -> Value {
if s.prefs.black_output == "rich" { merge(p.clone(), json!({ "richBlack": true })) } else { p.clone() }
}
/// A file name for bytes handed back without a path: the document's title with `ext`.
pub(crate) fn default_name(doc: &vectorcraft_doc::Document, ext: &str) -> String {
let stem = std::path::Path::new(&doc.title).file_stem().map(|s| s.to_string_lossy().into_owned()).filter(|s| !s.is_empty());
+2 -2
View File
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export PDF",
[],
None,
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null k Line truncated
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), form Line truncated
has_doc,
export_pdf
),
@@ -118,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
"PDF Settings",
[],
None,
"{preset?, includeDocument?: false, …document.exportPdf options} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…)",
"{preset?, includeDocument?: false, …options of document.exportPdf} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…)",
always,
pdf_settings
),
+18 -12
View File
@@ -36,6 +36,9 @@ pub struct LoadOptions {
/// layers, hidden objects and the art outside its artboards); else only what its page or PDF
/// part prints comes in.
pub editing_data: bool,
/// A `.ai` opens from its editing copy even where that draws differently from its PDF part
/// (which can be older than it), with a warning, instead of from the PDF part.
pub trust_editing_data: bool,
}
impl Default for LoadOptions {
@@ -50,6 +53,7 @@ impl Default for LoadOptions {
layers: true,
dxf: Default::default(),
editing_data: true,
trust_editing_data: false,
}
}
}
@@ -57,7 +61,7 @@ impl Default for LoadOptions {
impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`) with `grays`,
/// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false) and `dxf`
/// `textAs` (`text` | `outlines`), `layers` (true | false), `editingData` (true | false | `always`) and `dxf`
/// (the DXF import options).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
@@ -81,12 +85,13 @@ impl LoadOptions {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
};
let editing_data = match p.get("editingData").filter(|v| !v.is_null()) {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true or false"))?,
let (editing_data, trust_editing_data) = match p.get("editingData").filter(|v| !v.is_null()) {
None => (true, false),
Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true),
Some(v) => (v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true, false or \"always\""))?, false),
};
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data })
Ok(Self { pages, crop, password, color_mode, grays, text_as, layers, dxf, editing_data, trust_editing_data })
}
/// Does the document read only part of the file, or read it differently (a page range, another
@@ -97,9 +102,9 @@ impl LoadOptions {
}
/// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible
/// `.ai` asked for its crop box opens on its trim boxes, its editor's artboards, with the bleed
/// round them (its crop box takes the bleed in).
fn import_options(&self, bytes: &[u8]) -> Result<ImportOptions> {
/// `.ai`, or a PDF saved with its editing data, asked for its crop box opens on its trim boxes,
/// its editor's artboards, with the bleed round them (its crop box takes the bleed in).
fn import_options(&self, bytes: &[u8], editor_document: bool) -> Result<ImportOptions> {
let pages = match &self.pages {
None => None,
Some(range) => {
@@ -114,6 +119,7 @@ impl LoadOptions {
password: self.password.clone(),
text_as: self.text_as,
layers: self.layers,
uncalibrated_rgb: editor_document,
..Default::default()
})
}
@@ -130,10 +136,10 @@ fn choice<T: Choice + Default>(cmd: &str, key: &str, id: Option<&str>) -> Result
.ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", "))))
}
/// Import the pages `o` pick → the document and import notes.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?;
Ok((r.document, r.warnings))
/// Import the pages `o` pick → the document, import notes and what else the import tells.
/// `editor_document`: a `.ai` file.
pub(super) fn import(bytes: &[u8], o: &LoadOptions, editor_document: bool) -> Result<vectorcraft_pdf::ImportReport> {
vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes, editor_document)?).map_err(err)
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
+40 -17
View File
@@ -93,7 +93,10 @@ impl SavePlan {
/// File Info's dates for a save to a file: the modified date (and the created date, when it has
/// none) become `at`. Not an undo step.
pub fn stamp_save_dates(st: &mut DocState, at: i64) {
let d = std::sync::Arc::make_mut(&mut st.doc);
stamp_doc_dates(std::sync::Arc::make_mut(&mut st.doc), at);
}
fn stamp_doc_dates(d: &mut Document, at: i64) {
d.metadata.created.get_or_insert(at);
d.metadata.modified = Some(at);
}
@@ -296,6 +299,10 @@ pub struct SaveJob {
params: Value,
/// The document when the job was made: what counts as saved once the file is written.
snapshot: Arc<Document>,
/// The live document `snapshot` was made from, and the date stamped on `snapshot` (not on the
/// live document until the file is written).
source: Arc<Document>,
stamped: Option<i64>,
/// The artboards also saved to files of their own (`separateArtboards`).
boards: Vec<usize>,
}
@@ -417,24 +424,29 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
Ok(Encoded { warnings, ..Encoded::one(bytes) })
}
/// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the
/// document's own). Bad options fail here, before anything is encoded.
/// Snapshot the active document for `plan` (stamping File Info's dates on the snapshot when the
/// file becomes the document's own; the live document gets them in [`SaveJob::finish`]). Bad
/// options fail here, before anything is encoded.
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
// The web build, without a clock, leaves the dates unless given one.
if plan.path.is_some()
&& plan.retargets()
&& let Some(at) = clock_date(s, "modified", plan.modified)
{
stamp_save_dates(s.doc_mut()?, at);
}
job_for(s, s.doc()?, plan)
let at = if plan.path.is_some() && plan.retargets() { clock_date(s, "modified", plan.modified) } else { None };
job_for_stamped(s, s.doc()?, plan, at)
}
/// Snapshot `st` (any open document of `s`) for `plan`, without stamping dates (Data Recovery
/// snapshots documents that aren't active).
pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<SaveJob> {
job_for_stamped(s, st, plan, None)
}
fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i64>) -> Result<SaveJob> {
let cmd = plan.mode.command();
let own = doc_to_save(st, plan.format);
let mut own = doc_to_save(st, plan.format);
let mut snapshot = st.doc.clone();
if let Some(at) = stamped {
stamp_doc_dates(Arc::make_mut(&mut own), at);
stamp_doc_dates(Arc::make_mut(&mut snapshot), at);
}
// A native file records its links' paths relative to where it is written.
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p));
let doc = relative.map_or(own, Arc::new);
@@ -455,7 +467,7 @@ pub(crate) fn job_for(s: &Session, st: &DocState, plan: SavePlan) -> Result<Save
// Save keeps every artboard; exports may choose a subset instead.
o.insert("range".into(), json!("all"));
}
Ok(SaveJob { plan, doc, params, snapshot: st.doc.clone(), boards })
Ok(SaveJob { plan, doc, params, snapshot, source: st.doc.clone(), stamped, boards })
}
impl SaveJob {
@@ -593,6 +605,17 @@ impl SaveJob {
st.save_options = self.plan.options;
st.converted = false;
st.recovered = false;
if let Some(at) = self.stamped {
if Arc::ptr_eq(&st.doc, &self.source) {
st.doc = self.snapshot.clone();
} 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
// saved state: undoing back to it leaves the document unmodified.
stamp_save_dates(st, at);
return st.mark_saved_as(&self.source);
}
}
st.mark_saved_as(&self.snapshot);
}
}
@@ -688,7 +711,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), native (also flat): compress?: bool (gzip; default: the useCompression preference), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), native and .ai: separateArtboards?: false (also save each artboard of range?: \"1-3, 5\"|\"all\" (default) to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document), .ai: compress?: true (compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead, compress?: bool (native, also in options: gzip; default: the useCompression preference), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
@@ -697,7 +720,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save As…",
["File"],
Some("Cmd+Shift+S"),
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified? (as document.save)} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
has_doc,
|s, p| save(s, SaveMode::SaveAs, p)
),
@@ -706,7 +729,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save a Copy…",
["File"],
Some("Cmd+Alt+S"),
"{path?, format?, options?} (as file.saveAs) write a copy; the document keeps its path, title and modified state → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> copy.<ext>\", folder?, warnings}",
"{…as document.save} {path?, format?, options?} write a copy; the document keeps its path, title and modified state → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> copy.<ext>\", folder?, warnings}",
has_doc,
|s, p| save(s, SaveMode::Copy, p)
),
@@ -715,7 +738,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save as Template…",
["File"],
None,
"{path?, compress?, version?, preview? (as document.save)} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}",
"{…as document.save} {path?, compress?, version?, preview?} a native template (.vctemplate) that opens as a new untitled document; the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.vctemplate\", folder: the Templates folder (preference templatesFolder), warnings}",
has_doc,
|s, p| save(s, SaveMode::Template, p)
),
@@ -733,7 +756,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Revert",
["File"],
Some("F12"),
"{} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path}. The app asks first (a confirm dialog) unless confirmed: true",
"{confirmed?: bool (the app's own: it asks first, then runs the command with it)} discard the changes: reload the saved file into the same tab (history cleared; the tab keeps its place and view). Saved, modified documents only; if the file can't be read the document is left as it is → {path}. The app asks first (a confirm dialog) unless confirmed: true",
can_revert,
revert
),
+17 -7
View File
@@ -15,7 +15,9 @@ use vectorcraft_geom::Rect;
use super::super::*;
use super::encode::single_artboard;
use super::export::{isolated, without_artboards};
use super::{ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, writable, write_file};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
};
const C: &str = "document.exportForScreens";
@@ -141,6 +143,12 @@ fn safe_name(s: &str) -> Option<String> {
(!n.trim_matches('.').is_empty()).then_some(n)
}
/// Text that goes into a file name (prefix, suffix): path separators and `:` become `-`, so it can't
/// start a folder or climb out of the export folder.
fn file_part(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
}
/// Keys of a format row that aren't encoder options.
const ROW_KEYS: [&str; 7] = ["format", "scale", "ppi", "width", "height", "suffix", "folder"];
@@ -169,7 +177,7 @@ impl Row {
let size = if format.raster { ScreenSize::of_row(row)? } else { ScreenSize::Scale(1.0) };
let suffix = match str_param(row, "suffix") {
Some(s) if !format.raster && is_size_suffix(s) => String::new(),
Some(s) => s.to_string(),
Some(s) => file_part(s),
None => size.suffix(),
};
let folder = match str_param(row, "folder").and_then(safe_name) {
@@ -336,7 +344,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let subfolders = o.subfolders || o.preset.is_some();
// A prefix names files, not folders.
let prefix: String = o.prefix.as_deref().unwrap_or("").chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect();
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
if let Some(dir) = folder {
create_dir(dir)?;
@@ -357,6 +365,8 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
if subfolders { format!("{}/{file}", r.folder) } else { file }
};
let mut out = Output::default();
// 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));
if o.full_document {
let stem = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
// The bounds of all visible art, as the one artboard of the files that aren't PDFs.
@@ -370,7 +380,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
// One page per artboard.
let mut options = r.options.clone();
options["range"] = json!("all");
encode(&doc, "pdf", &options)?
write(&doc, "pdf", &options)?
} else {
if whole.is_none() {
let bounds =
@@ -379,7 +389,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let Some(d) = whole.as_ref() else { continue };
let region = d.artboards.first().map_or(Rect::ZERO, |a| a.rect);
encode(d, r.format.id, &r.options_for(region, Some(0))?)?
write(d, r.format.id, &r.options_for(region, Some(0))?)?
};
out.put(folder, o.zip, name, bytes)?;
}
@@ -397,7 +407,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
if !out.claim(&name) {
continue;
}
let bytes = encode(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
let bytes = write(&d, r.format.id, &r.options_for(bounds, Some(0))?)?;
out.put(folder, o.zip, name, bytes)?;
}
}
@@ -414,7 +424,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
if !out.claim(&name) {
continue; // the same file from two identical rows
}
let bytes = encode(&doc, r.format.id, &r.options_for(region, Some(b))?)?;
let bytes = write(&doc, r.format.id, &r.options_for(region, Some(b))?)?;
out.put(folder, o.zip, name, bytes)?;
}
}
+44 -2
View File
@@ -159,9 +159,10 @@ fn open_exts_cover_every_readable_format() {
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(
filters.len(),
7 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, plug-ins"
9 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, shortcut sets, scripts, plug-ins"
);
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
}
@@ -485,3 +486,44 @@ fn a_save_that_fails_while_streaming_leaves_the_file_as_it_was() {
}
let _ = std::fs::remove_dir_all(&d);
}
/// A save that fails leaves File Info's dates and the saved state alone; one that works stamps them.
#[test]
fn a_failed_save_does_not_stamp_the_documents_dates() {
let mut s = session(200.0, 100.0, 1);
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
let before = s.doc().unwrap().doc.metadata.clone();
let dirty = s.doc().unwrap().is_dirty();
let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": "/x/y.vectorcraft", "modified": 12345})).unwrap();
let e = save_with(&mut s, plan, |_, _| Err(EngineError::Other("disk full".into()))).unwrap_err();
assert_eq!(e.to_string(), "disk full");
let st = s.doc().unwrap();
assert_eq!(st.doc.metadata, before);
assert_eq!(st.is_dirty(), dirty);
// Options the encoder refuses: the same.
assert!(s.execute("file.saveAs", &json!({"path": "/x/y.vectorcraft", "version": 99, "modified": 12345})).is_err());
let st = s.doc().unwrap();
assert_eq!(st.doc.metadata, before);
assert_eq!(st.is_dirty(), dirty);
let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": "/x/y.vectorcraft", "modified": 12345})).unwrap();
save_with(&mut s, plan, |_, _| Ok(())).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.metadata.modified, Some(12345));
assert!(!st.is_dirty());
// Edited while the file is written (Background Save): the dates go on once it is, and the
// edit keeps the document modified.
let plan = save_plan(&s, SaveMode::SaveAs, &json!({"path": "/x/z.vectorcraft", "modified": 777})).unwrap();
let job = save_job(&mut s, plan).unwrap();
let uid = s.doc().unwrap().uid;
assert_eq!(s.doc().unwrap().doc.metadata.modified, Some(12345));
s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
job.write(|_, _| Ok(())).unwrap();
job.complete(&mut s, uid);
let st = s.doc().unwrap();
assert_eq!(st.doc.metadata.modified, Some(777));
assert_eq!(st.path.as_deref(), Some("/x/z.vectorcraft"));
assert!(st.is_dirty());
// Undoing the edit is back at what the file holds.
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
}
+72 -15
View File
@@ -154,6 +154,52 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
assert!(e.contains("placeholder"), "{e}");
}
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
/// The kinds of what the first layer holds: type (or its outlines, without the font) and others.
fn text_and_others(s: &Session) -> (usize, usize) {
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
let text = art.iter().filter(|n| matches!(n.kind, NodeKind::Text(_)) || n.name.as_deref() == Some("<Text Outlines>")).count();
(text, art.len() - text)
}
#[test]
fn an_ai_file_whose_pdf_part_is_only_text_opens_from_it_when_its_editing_data_cant_be_read() {
// #1387. The page states where its art is (an art, trim or bleed box), which the notice
// page of a file saved without its PDF part has no art to do.
let unreadable = b"%AI24_ZStandard_Data\0\0\0\0\0\0\0\0";
for boxed in ["/ArtBox [0 0 200 100]", "/TrimBox [0 0 200 100]", "/BleedBox [0 0 200 100]"] {
let mut s = Session::new();
let r = open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({})).unwrap();
// Then a note on the font, where the machine lacks it.
assert_eq!(r["warnings"][0], json!(vectorcraft_pdf::OFF_ARTBOARD_NOTE), "{boxed}: {r}");
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
// The PDF part alone, as asked.
open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({"editingData": false})).unwrap();
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
}
// The same page without a box is the notice: the file still says why it can't open.
let mut s = Session::new();
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("can't be read"), "{e}");
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({"editingData": false})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
// Placed, it is read as its page too.
let r = s.execute("file.new", &json!({})).and_then(|_| {
let data = vectorcraft_format::base64_encode(&ai::ai_with(unreadable, TEXT_ART, "/ArtBox [0 0 200 100]"));
s.execute("file.place", &json!({"name": "text.ai", "dataBase64": data, "link": false}))
});
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn an_ai_file_whose_pdf_part_is_only_text_still_opens_from_editing_data_that_reads() {
let mut s = Session::new();
open(&mut s, "art.ai", &ai::ai_with(&compressed(&ai::sample_data()), TEXT_ART, "/ArtBox [0 0 200 100]"), json!({})).unwrap();
check_sample(&s);
}
#[test]
fn editing_data_false_opens_only_what_the_page_prints() {
let mut s = Session::new();
@@ -165,7 +211,10 @@ fn editing_data_false_opens_only_what_the_page_prints() {
assert_ne!(names(layers), ["Art"], "{name}: the page's layer");
}
let e = open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({"editingData": "no"})).unwrap_err().to_string();
assert!(e.contains("editingData must be true or false"), "{e}");
assert!(e.contains("editingData must be true, false or \"always\""), "{e}");
// `always`: the editing data, as by default (whether it draws as the page does is the reader's).
open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), json!({"editingData": "always"})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
// A file that is only its editing data can't open without it.
let e = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({"editingData": false})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
@@ -205,40 +254,48 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
/// A `.ai` of a 200 × 100 pt artboard with a 10 mm bleed: its PDF part's page is the artboard
/// (its trim box) grown by the bleed, and a red square reaches 20 pt into the bleed, on the page
/// as in the editing data.
fn bled_ai(bleed: f64) -> Vec<u8> {
/// as in the editing data. `key`: `AIPrivateData`, or `AIPDFPrivateData` for a PDF saved with its
/// editing data.
fn bled_ai(bleed: f64, key: &str) -> Vec<u8> {
let art = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(-20.0, -20.0, 60.0, 50.0));
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &art));
let (w, h) = (200.0 + 2.0 * bleed, 100.0 + 2.0 * bleed);
let page = PdfPage {
bleed: Some([0.0, 0.0, w, h]),
trim: Some([bleed, bleed, 200.0 + bleed, 100.0 + bleed]),
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
entries: format!("/PieceInfo << /Illustrator << /Private << /{key}1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
..PdfPage::new(w, h, &format!("1 0 0 rg {0} {0} 80 70 re f", bleed - 20.0))
};
pdf_with(&[page], &[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len())], None)
}
#[test]
fn an_ai_file_with_a_bleed_opens_on_its_artboard_with_the_bleed_round_it() {
fn a_file_with_a_bleed_opens_on_its_artboard_with_the_bleed_round_it() {
// A `.ai`, and a PDF saved with its editing data (#1345), which has it under other keys.
for (name, key) in [("bled.ai", "AIPrivateData"), ("bled.pdf", "AIPDFPrivateData")] {
bled_file_opens_on_its_artboard(name, key);
}
}
fn bled_file_opens_on_its_artboard(name: &str, key: &str) {
let bleed = 10.0 * 72.0 / 25.4;
let file = bled_ai(bleed);
let file = bled_ai(bleed, key);
let mut s = Session::new();
// From the editing data, and from the PDF part alone.
for params in [json!({}), json!({"editingData": false})] {
let r = open(&mut s, "bled.ai", &file, params.clone()).unwrap();
let r = open(&mut s, name, &file, params.clone()).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.artboards.len(), 1, "{params}: {r}");
assert_eq!(doc.artboards.len(), 1, "{name} {params}: {r}");
let ab = doc.artboards[0].rect;
assert!((ab.width() - 200.0).abs() < 0.01 && (ab.height() - 100.0).abs() < 0.01, "{params}: {ab:?} {r}");
assert!(doc.setup.bleed.iter().all(|b| (b - bleed).abs() < 0.01), "{params}: {:?}", doc.setup.bleed);
assert!((ab.width() - 200.0).abs() < 0.01 && (ab.height() - 100.0).abs() < 0.01, "{name} {params}: {ab:?} {r}");
assert!(doc.setup.bleed.iter().all(|b| (b - bleed).abs() < 0.01), "{name} {params}: {:?}", doc.setup.bleed);
// The square reaches into the bleed, whole.
let art = doc.art_bounds().unwrap();
let want = [ab.x0 - 20.0, ab.y0 + 50.0, ab.x0 + 60.0, ab.y0 + 120.0];
assert!([art.x0, art.y0, art.x1, art.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{params}: {art:?}");
assert!(doc.setup.bleed_rect(ab).inflate(0.01, 0.01).contains_rect(art), "{params}: {art:?}");
assert!([art.x0, art.y0, art.x1, art.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{name} {params}: {art:?}");
assert!(doc.setup.bleed_rect(ab).inflate(0.01, 0.01).contains_rect(art), "{name} {params}: {art:?}");
}
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Page 1"], "editingData: false reads the PDF part");
open(&mut s, "bled.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers are read");
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Page 1"], "{name}: editingData: false reads the PDF part");
open(&mut s, name, &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{name}: the editing data's layers are read");
}
@@ -106,6 +106,59 @@ fn screens_and_rasterize_take_anti_aliasing() {
assert!(s.execute("object.rasterize", &json!({"antiAlias": "fuzzy"})).is_err());
}
/// Appearance of Black › Printing / Exporting (#394): Output All Blacks as Rich Black makes an
/// RGB PNG of a CMYK document draw pure black ink as RGB black; Accurately (the default) shows it
/// through the CMYK profile. `blackOnScreen` does not change the export; a rich black is the same
/// either way.
#[test]
fn black_output_rich_draws_pure_cmyk_black_as_rgb_black_in_png() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 20, "height": 20, "colorMode": "cmyk"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
let centre = |s: &mut Session| {
let png = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "background": "white"})).unwrap()));
png.get_pixel(10, 10).0
};
let accurate = centre(&mut s);
let shown = vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgba8(1.0);
assert!(accurate.iter().zip(shown).all(|(a, b)| a.abs_diff(b) <= 1), "accurate: {accurate:?}");
assert!(accurate[0] > 10, "accurate: dark grey, not RGB black");
s.execute("prefs.set", &json!({"key": "blackOnScreen", "value": "rich"})).unwrap();
assert_eq!(centre(&mut s), accurate, "blackOnScreen does not change exports");
s.execute("prefs.set", &json!({"key": "blackOutput", "value": "rich"})).unwrap();
assert_eq!(centre(&mut s), [0, 0, 0, 255], "rich: RGB black");
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0.6, "m": 0.4, "y": 0.4, "k": 1}})).unwrap();
let rich = centre(&mut s);
s.execute("prefs.set", &json!({"key": "blackOutput", "value": "accurate"})).unwrap();
assert_eq!(centre(&mut s), rich, "a rich black shows the same either way");
}
/// Output All Blacks as Rich Black reaches a CMYK document composed from ink planes (one with a
/// blending mode) as it does one drawn directly, and leaves CMYK-model exports alone: only RGB
/// output changes.
#[test]
fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 40, "height": 20, "colorMode": "cmyk"})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
s.execute("shape.rectangle", &json!({"x": 30, "y": 0, "width": 10, "height": 20})).unwrap();
s.execute("transparency.set", &json!({"blendMode": "multiply"})).unwrap();
let png =
|s: &mut Session| rgba(&b64(&s.execute("document.export", &json!({"format": "png", "background": "white"})).unwrap())).get_pixel(10, 10).0;
let cmyk = |s: &mut Session, format: &str| b64(&s.execute("document.export", &json!({"format": format, "colorModel": "cmyk"})).unwrap());
let accurate = png(&mut s);
assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}");
let inks = [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")];
s.execute("prefs.set", &json!({"key": "blackOutput", "value": "rich"})).unwrap();
assert_eq!(png(&mut s), [0, 0, 0, 255], "rich: RGB black through the ink planes too");
assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks");
}
#[test]
fn formats_list_the_raster_options() {
let mut s = Session::new();
@@ -163,3 +216,20 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
assert!((art.width() - w).abs() < 1e-6 && (art.height() - h).abs() < 1e-6, "{what}: the image fills it, {art:?}");
}
}
#[test]
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
// the renderer can't decode it and draws nothing, which the export must say.
let mut s = session(72.0, 36.0, 1);
let mut png = Vec::new();
image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let cut = png_chunk(&png, b"IDAT").map(|d| d.as_ptr() as usize - png.as_ptr() as usize).unwrap() + 4;
png.truncate(cut);
s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
}
Loaded 100 of 532 files, more files were not shown because too many files have changed in this diff. Show more