Compare commits

...
60 Commits
Author SHA1 Message Date
skylar taylor-barrick 52960faf63 PDF.W1: reconcile shared writer with current main 2026-10-14 01:23:19 -06: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
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
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
121 changed files with 3694 additions and 524 deletions

No files matched your search

Generated
+36 -17
View File
@@ -809,7 +809,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
source = "git+https://github.com/storytold/craft-libs?rev=f39922b214dc429bcf6551ef71ed3631337b2acf#f39922b214dc429bcf6551ef71ed3631337b2acf"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1313,6 +1313,12 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "fearless_simd"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -1769,9 +1775,9 @@ dependencies = [
[[package]]
name = "hayro-ccitt"
version = "0.3.0"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad"
[[package]]
name = "hayro-cmap"
@@ -1785,9 +1791,9 @@ dependencies = [
[[package]]
name = "hayro-interpret"
version = "0.7.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
@@ -1797,28 +1803,28 @@ dependencies = [
"phf",
"rustc-hash 2.1.3",
"siphasher",
"skrifa 0.42.1",
"skrifa 0.46.2",
"smallvec",
"yoke",
]
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
dependencies = [
"fearless_simd",
"fearless_simd 1.1.0",
"hayro-ccitt",
]
[[package]]
name = "hayro-jpeg2000"
version = "0.3.5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835"
dependencies = [
"fearless_simd",
"fearless_simd 1.1.0",
]
[[package]]
@@ -1829,9 +1835,9 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026"
dependencies = [
"flate2",
"hayro-ccitt",
@@ -2230,7 +2236,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
source = "git+https://github.com/storytold/craft-libs?rev=f39922b214dc429bcf6551ef71ed3631337b2acf#f39922b214dc429bcf6551ef71ed3631337b2acf"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -3841,6 +3847,16 @@ dependencies = [
"read-fonts 0.41.0",
]
[[package]]
name = "skrifa"
version = "0.46.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6"
dependencies = [
"bytemuck",
"read-fonts 0.43.3",
]
[[package]]
name = "skrifa"
version = "0.47.0"
@@ -4776,6 +4792,7 @@ dependencies = [
"hayro-syntax",
"image",
"jpeg-encoder",
"krilla",
"kurbo 0.13.1",
"log",
"md-5",
@@ -4792,6 +4809,7 @@ dependencies = [
"vectorcraft-geom",
"vectorcraft-testkit",
"vectorcraft-text",
"weezl",
]
[[package]]
@@ -4967,6 +4985,7 @@ dependencies = [
"vectorcraft-three-d",
"vectorcraft-tools",
"wat",
"web-time",
]
[[package]]
@@ -4994,7 +5013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
dependencies = [
"bytemuck",
"fearless_simd",
"fearless_simd 0.4.1",
"guillotiere",
"log",
"peniko",
@@ -5009,7 +5028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
"fearless_simd 0.4.1",
"guillotiere",
"log",
"peniko",
+5 -2
View File
@@ -11,7 +11,7 @@ rust-version = "1.95"
repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "f39922b214dc429bcf6551ef71ed3631337b2acf", default-features = false, features = ["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
@@ -62,6 +62,9 @@ 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"
# 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"
@@ -111,4 +114,4 @@ lto = "thin"
codegen-units = 1
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "f39922b214dc429bcf6551ef71ed3631337b2acf" }
+4 -1
View File
@@ -8,7 +8,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (PDF.W1: guarded shared PDF writer; output behavior, scores and estimates 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (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
@@ -131,6 +131,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| 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
@@ -168,6 +170,7 @@ 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. |
+4 -6
View File
@@ -434,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.
@@ -517,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(())
}
+30 -1
View File
@@ -29,6 +29,21 @@ 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,
@@ -106,9 +121,10 @@ fn spawn_connection(
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(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
Some(rrx.recv_timeout(REPLY_WAIT).unwrap_or_else(|_| timed_out(&ticket)))
});
})
}
@@ -455,6 +471,19 @@ mod tests {
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\"}");
+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 -1
View File
@@ -506,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
+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
+77 -9
View File
@@ -31,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;
@@ -775,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 {
@@ -783,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.
@@ -1376,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)
@@ -1460,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 {
@@ -1499,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));
}
}
@@ -1628,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();
+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);
+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> {
+2 -2
View File
@@ -226,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.
pub(crate) 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(),
_ => &[],
@@ -235,7 +235,7 @@ pub(crate) fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
}
/// Mutable [`node_paints`], in the same order.
pub(crate) 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 [],
+17
View File
@@ -108,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
@@ -115,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 };
@@ -132,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;
+14 -12
View File
@@ -113,19 +113,19 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> {
}
}
fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
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, bounds, extrude_options(p), view),
INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view),
_ => vectorcraft_three_d::plane(path, bounds, view),
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, _) =
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")
@@ -134,7 +134,7 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
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, b, p).map(|_| ())
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
@@ -185,11 +185,12 @@ fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
}
}
/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke.
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData {
/// 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),
_ => path.clone(),
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => (crate::stroke::outline_region(path, rule, s), FillRule::EvenOdd),
_ => (path.clone(), rule),
}
}
@@ -204,7 +205,8 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
let mut out = vec![];
for item in items {
let (paint, opacity, blend) = paint_of(item);
let f = facets(ROTATE, &region(path, rule, item), b, &p).ok()?;
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);
@@ -213,7 +215,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
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, b, &p).ok()?;
let f = facets(&fx.id, path, rule, b, &p).ok()?;
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
}
+18
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());
+1 -1
View File
@@ -265,7 +265,7 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
/// 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.
fn settle_interaction(s: &mut Session) -> Result<()> {
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
let typing = typing_in_progress(s);
s.commit_interaction()?;
if typing {
+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);
}
}
+11 -6
View File
@@ -273,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)
}
@@ -419,7 +424,7 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
/// 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.
pub(crate) 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
+1
View File
@@ -457,6 +457,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
pub(crate) use edit::{settle_interaction, typing_in_progress};
pub(crate) use vectorcraft_doc::range::parse_range;
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
+38 -10
View File
@@ -202,7 +202,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Bounds",
[],
None,
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
has_selection,
set_bounds
),
@@ -1400,6 +1400,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let st = s.doc()?;
if transforms_objects(p)
&& !bool_or(p, "copy", false)
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
{
return set_anchor_bounds(s, p, ids, b);
}
let d = &s.doc()?.doc;
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
// the math below runs in its frame.
@@ -1419,15 +1426,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]];
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let rp = vectorcraft_geom::reference_point(v, refi);
let mut w = f64_or(p, "width", v.width());
let mut h = f64_or(p, "height", v.height());
if bool_or(p, "proportional", false) {
if p.get("width").is_some() && v.width() > 0.0 {
h = v.height() * w / v.width();
} else if p.get("height").is_some() && v.height() > 0.0 {
w = v.width() * h / v.height();
}
}
let (w, h) = wanted_size(p, v);
// The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is
// exact for proportional scales of one path; [`Spans::refit`] checks it, and solves for the
// scales where it misses.
@@ -1477,6 +1476,35 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
}
/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or
/// with `proportional` follows the one given.
fn wanted_size(p: &Value, b: Rect) -> (f64, f64) {
let mut w = f64_or(p, "width", b.width());
let mut h = f64_or(p, "height", b.height());
if bool_or(p, "proportional", false) {
if p.get("width").is_some() && b.width() > 0.0 {
h = b.height() * w / b.width();
} else if p.get("height").is_some() && b.height() > 0.0 {
w = b.width() * h / b.height();
}
}
(w, h)
}
/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of
/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only
/// the anchors, their handles with them, and those objects move or scale.
fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec<NodeId>, b: Rect) -> Result<Value> {
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let rp = vectorcraft_geom::reference_point(b, refi);
let (w, h) = wanted_size(p, b);
// A single anchor, or anchors in a line, have no size to scale on that side.
let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 };
let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height())));
let to = Point::new(f64_or(p, "x", rp.x), f64_or(p, "y", rp.y));
apply_transform(s, "Transform", ids, Affine::translate(to - rp) * scale, p, true)
}
/// The visual box of `ids` square to `frame`, as [`Document::oriented_bounds`] measures it, with
/// symbol instances measured without the strokes in their art
/// ([`vectorcraft_doc::SymbolBoxes::without_art_strokes`]).
+1 -1
View File
@@ -594,7 +594,7 @@ pub(crate) fn outlined_stroke(
st: &StrokeLayer,
) -> Option<Node> {
if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st);
let mut pieces = vectorcraft_brush::stroke_pieces(d, b, &path.to_bezpath(), st);
let art = match pieces.len() {
0 => return None,
1 => pieces.remove(0),
+4
View File
@@ -90,9 +90,13 @@ fn map_of(src: &[Arc<Node>]) -> Result<BuilderMap> {
}
let arrangement = po::shape_builder(&shapes, true);
let bounded = |pieces: Vec<po::Shape>| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect();
// A Shaper Group's faces are what its shapes fill and what its open paths enclose: the gaps
// closed paths enclose are the Shape Builder's only.
let open = po::has_open_path(&shapes);
let regions = arrangement
.regions
.into_iter()
.filter(|r| open || !r.sources.is_empty())
.filter_map(|r| {
let bounds = r.path.bounds()?;
let path = r.path.to_bezpath();
+34
View File
@@ -113,4 +113,38 @@ mod tests {
s.execute("text.tabs.clear", &json!({})).unwrap();
assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"], json!([]));
}
/// #1877: a leader set the way the Tabs panel sets it fills the tab's gap with its dots.
#[test]
fn tab_leader_fills_the_gap_with_its_characters() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "AAA\t20", "size": 12})).unwrap()["id"].as_u64().unwrap();
s.execute("text.tabs.set", &json!({"stops": [{"position": 200, "align": "left", "leader": ".", "alignOn": "."}]})).unwrap();
let st = s.doc().unwrap();
let Some(vectorcraft_doc::NodeKind::Text(t)) = st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) else { panic!() };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let x_of = |b: usize| lay.glyphs.iter().find(|g| g.byte == b).unwrap().origin.x;
let (from, to) = (x_of(3), x_of(4));
assert!(to - from > 150.0, "{from}..{to}");
// Contours drawn inside the gap: only the leader's dots can be there.
let mut dots = 0;
for g in &lay.glyphs {
let mut contours: Vec<kurbo::BezPath> = vec![];
for el in g.outline.elements() {
match (el, contours.last_mut()) {
(kurbo::PathEl::MoveTo(_), _) | (_, None) => contours.push(kurbo::BezPath::from_vec(vec![*el])),
(_, Some(c)) => c.push(*el),
}
}
for c in contours {
let r = kurbo::Shape::bounding_box(&c);
if r.x0 > from - 1e-6 && r.x1 < to + 1e-6 {
assert!(r.width() < 3.0 && r.height() < 3.0, "not a dot: {r:?}");
dots += 1;
}
}
}
assert!(dots > 30, "{dots} dots in a {} pt gap", to - from);
}
}
+46
View File
@@ -821,6 +821,32 @@ fn transforms_act_on_direct_selected_anchors() {
assert_eq!(points(&s), [(25.0, 0.0), (75.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
}
/// #1824: with anchors selected with Direct Selection, the Transform panel's X/Y/W/H
/// (`object.setBounds`) are the box around those anchors, and only they move or scale.
#[test]
fn set_bounds_acts_on_direct_selected_anchors() {
let mut s = session();
let square = json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true});
let id = NodeId(s.execute("path.create", &square).unwrap()["id"].as_u64().unwrap());
let points = |s: &Session| -> Vec<(f64, f64)> {
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") };
path.subpaths[0].anchors.iter().map(|a| (a.p.x.round(), a.p.y.round())).collect()
};
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
s.execute("object.setBounds", &json!({"x": 130, "reference": 0})).unwrap();
assert_eq!(points(&s), [(0.0, 0.0), (130.0, 0.0), (100.0, 100.0), (0.0, 100.0)], "only the selected anchor moves");
s.execute("object.setBounds", &json!({"y": 20, "reference": 4})).unwrap();
assert_eq!(points(&s), [(0.0, 0.0), (130.0, 20.0), (100.0, 100.0), (0.0, 100.0)]);
// Two anchors: W scales their box about the reference point, the others stay.
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3]]})).unwrap();
s.execute("object.setBounds", &json!({"width": 50, "reference": 3})).unwrap();
assert_eq!(points(&s), [(0.0, 0.0), (130.0, 20.0), (50.0, 100.0), (0.0, 100.0)]);
// The path selected whole moves as a whole.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.setBounds", &json!({"x": 10, "reference": 0})).unwrap();
assert_eq!(points(&s), [(10.0, 0.0), (140.0, 20.0), (60.0, 100.0), (10.0, 100.0)]);
}
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
/// Current Layer on objects already on top of the current layer.
@@ -1480,6 +1506,26 @@ fn direct_selection_moves_one_anchor() {
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(200.0, 100.0));
}
/// #1816: with Snap to Grid on, a dragged anchor lands on the grid's subdivisions, not on whole
/// points, even with Snap to Pixel left on too.
#[test]
fn direct_selection_snaps_anchors_to_the_grid() {
let mut s = session();
let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0);
s.execute("select.none", &json!({})).unwrap();
let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1));
let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;
let v = ViewInfo { snap_to_grid: true, snap_to_pixel: true, ..ViewInfo::default() };
s.select_tool("directSelection", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 101.0, 101.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 131.0, 113.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 131.0, 113.0), v).unwrap();
let p = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().clone();
let moved = p.subpaths[0].anchors[0].p;
assert!(on_grid(moved.x) && on_grid(moved.y), "anchor at {moved:?}, grid step {step}");
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(201.0, 101.0));
}
/// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all
/// its anchors, so toggling one deselects it alone and adding one keeps them all.
#[test]
+33
View File
@@ -152,6 +152,39 @@ fn shape_builder_regions_query() {
assert!(regs.iter().any(|x| x["sources"].as_array().unwrap().len() == 2));
}
/// #1691: four circles in a 2×2 grid, each touching its neighbours, enclose a diamond nothing
/// fills. It is a region, and a drag from a circle into it merges the two.
#[test]
fn shape_builder_merges_the_gap_touching_circles_enclose() {
let mut s = session();
let mut circles = vec![];
for (x, y) in [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0), (100.0, 100.0)] {
let r = s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap();
circles.push(NodeId(r["id"].as_u64().unwrap()));
}
set_fill(&mut s, circles[0], Color::rgb(1.0, 0.0, 0.0));
s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
let regs = r["regions"].as_array().unwrap();
assert_eq!(regs.len(), 5, "{regs:?}");
assert_eq!(regs.iter().filter(|x| x["sources"].as_array().unwrap().is_empty()).count(), 1, "the diamond");
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [100, 100]]})).unwrap();
assert_eq!(r["regions"], 2);
let out = ids(&r);
assert_eq!(out.len(), 4, "the merged shape and the three other circles");
let merged = NodeId(r["merged"].as_u64().unwrap());
// A circle (its Bézier outline: 7,854.9) and the diamond between the four (2,145.1).
assert!((area(&s, merged) - 10000.0).abs() < 10.0, "{}", area(&s, merged));
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the look of the circle the drag began on");
assert_eq!(layer_children(&s), 4);
// A click in the diamond alone makes it a shape of its own.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
let r = s.execute("shapeBuilder.merge", &json!({"points": [[100, 100]]})).unwrap();
assert_eq!(ids(&r).len(), 5);
assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 2146.0).abs() < 10.0);
}
#[test]
fn shape_builder_tool_drives_the_command() {
let mut s = session();
+26
View File
@@ -314,6 +314,32 @@ fn a_copy_stays_inside_the_write_root() {
let _ = std::fs::remove_dir_all(&root);
}
/// An SVG's linked files are read on the importer's own thread (it needs a deep stack): the roots
/// in force on the thread that opens the SVG hold there too.
#[test]
fn an_svg_reads_its_links_within_the_read_root() {
let root = folder("svg-roots");
for dir in ["in", "out"] {
write(&root.join(dir).join("dot.png"), &png(4, 4, RED));
}
// What came of an SVG linking to the picture in `dir`: the pictures read, and the warnings.
let import = |dir: &str| {
let href = root.join(dir).join("dot.png").to_string_lossy().replace('\\', "/");
let svg =
format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"><image width=\"4\" height=\"4\" href=\"{href}\"/></svg>");
let (doc, warnings) = crate::cmd::fileio::import_svg(&svg, None).unwrap();
(doc.images.len(), warnings)
};
let (inside, outside) = (import("in"), import("out"));
assert_eq!(inside, outside, "both are read with no roots in force");
let roots = crate::file_access::AutomationRoots::new(Some(&root.join("in")), None).unwrap();
crate::file_access::confine(roots.as_ref(), || {
assert_eq!(import("in"), inside, "inside the read root: read as before");
assert_ne!(import("out"), outside, "outside the read root: not read");
});
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn junk_search_params_never_fail_internally() {
let root = folder("junk");
+24
View File
@@ -150,6 +150,30 @@ fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() {
assert_eq!(obj(&s, id).plain_text(), "abc!");
}
/// #1881: a Cmd/Ctrl drag while typing (the Selection tool lent to the Type tool) moves the type
/// and keeps what was typed, and typing goes on after it.
#[test]
fn a_cmd_drag_while_typing_moves_the_type_and_keeps_the_typing() {
let mut s = session();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
click(&mut s, 200.0, 200.0);
let id = s.doc().unwrap().selection.objects[0];
s.tool_text("Hello", v).unwrap();
let before = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
let (at, cmd) = (before.center(), Mods { cmd: true, ..Default::default() });
for (kind, dx) in [(PointerKind::Down, 0.0), (PointerKind::Drag, 100.0), (PointerKind::Up, 100.0)] {
s.pointer(&PointerEvent::new(kind, at.x + dx, at.y).with_mods(cmd), v).unwrap();
}
assert_eq!(obj(&s, id).plain_text(), "Hello", "the typing is kept");
let moved = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
assert!(moved.x0 - before.x0 > 50.0, "the type moved: {before:?} → {moved:?}");
s.tool_text("!", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "Hello!");
let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
assert!((after.x0 - moved.x0).abs() < 1e-6, "and stays where it was moved: {after:?}");
}
#[test]
fn undo_during_a_drag_takes_back_only_the_drag() {
let mut s = session();
+9 -1
View File
@@ -115,6 +115,12 @@ impl Session {
return *ev;
}
let Some(id) = self.lent_tool(self.tool.id()) else { return *ev };
// Typing in progress is kept as its own step first: the lent tool's drag previews from the
// document its interaction started from, which would drop the typing (#1881).
if crate::cmd::typing_in_progress(self) {
// Fails only without a document, and then there is no typing to keep.
let _ = crate::cmd::settle_interaction(self);
}
let lent = self.make_tool(id);
self.lender = Some(std::mem::replace(&mut self.tool, lent));
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
@@ -188,7 +194,9 @@ impl Session {
guides: view.guides && !self.menu.guides_locked,
snap_to_grid: view.snap_to_grid,
show_bbox: view.show_bbox,
snap_to_pixel: view.snap_to_pixel,
// Snap to Grid and Snap to Pixel are exclusive, as in Illustrator; left both on, the grid
// wins instead of pixels quietly rounding every drag to whole points (#1816).
snap_to_pixel: view.snap_to_pixel && !view.snap_to_grid,
snap_to_point: view.snap_to_point,
corner_widgets: view.corner_widgets,
fill_active: self.fill_active,
+77
View File
@@ -56,6 +56,48 @@ fn legacy_profile_name_resolves_in_commands_and_files() {
cms::set_active(&before).unwrap();
}
/// Separations Preview follows the Color Settings and the profiles loaded: a renderer draws a gray
/// square on the Black plate through the new working CMYK profile, and the four process plates
/// through a proof profile loaded again under the same name, as a new renderer draws them.
#[test]
fn separations_preview_draws_through_new_color_settings() {
let _g = settings();
let mut s = session();
let gray = rect(&mut s, 20.0, 20.0, 160.0, 120.0);
exec(&mut s, "paint.setFill", json!({"ids": [gray.0], "color": "#808080"}));
// The proof and Overprint Preview options the canvas takes from the view state the
// commands set.
let canvas = || RenderOptions {
proof: vectorcraft_render::proof::active_proof(),
overprint_preview: vectorcraft_render::proof::overprint_preview_on(),
background: Some([255, 255, 255, 255]),
..Default::default()
};
let frame = |r: &mut Renderer, s: &Session| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &canvas()).pixels;
exec(&mut s, "view.separationsPreview", json!({"only": "Black"}));
let mut r = Renderer::new();
let before = frame(&mut r, &s);
let old = cms::active_settings();
s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
let fresh = frame(&mut Renderer::new(), &s);
let warm = frame(&mut r, &s);
cms::set_active(&old).unwrap();
assert!(fresh != before, "the new profile changes the plate");
assert!(warm == fresh);
let name = "Separations Proof CMYK";
cms::register_icc(&cms::icc_bytes(cms::GENERIC_CMYK).unwrap(), Some(name.into())).unwrap();
exec(&mut s, "view.proofSetup", json!({"target": format!("cmyk:{name}")}));
exec(&mut s, "view.separationsPreview", json!({"plates": ["Cyan", "Magenta", "Yellow", "Black"]}));
let before = frame(&mut r, &s);
cms::register_icc(&cms::icc_bytes(cms::DEVICE_CMYK).unwrap(), Some(name.into())).unwrap();
let fresh = frame(&mut Renderer::new(), &s);
let warm = frame(&mut r, &s);
exec(&mut s, "view.proofSetup", json!({"target": "workingCmyk"}));
exec(&mut s, "view.separationsPreview", json!({"on": false}));
assert!(fresh != before, "the profile loaded again changes the plates");
assert!(warm == fresh);
}
/// Raster effects follow the Color Settings: a blurred CMYK fill, and a white square's drop shadow
/// in a CMYK color, are drawn again through the new working CMYK profile, in a frame drawn while
/// interacting (as during a dialog's preview) and after it.
@@ -85,6 +127,41 @@ fn raster_effects_draw_through_new_color_settings() {
cms::set_active(&old).unwrap();
}
/// Pattern tiles follow the Color Settings: a fill of a pattern in a CMYK color is drawn through
/// the new working CMYK profile in the next frame, as a renderer on a new thread (with no tiles of
/// its own yet) draws it.
#[test]
fn pattern_tiles_draw_through_new_color_settings() {
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_geom::Rect;
use vectorcraft_geom::shapes::rectangle;
let _g = settings();
let old = cms::active_settings();
let mut d = Document::new(200.0, 100.0);
let cyan = Appearance::basic(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), Paint::None, 0.0);
let square = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), cyan);
let mut def = PatternDef::new("Checks", vec![Arc::new(square)]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
d.patterns.push(def);
let checks = Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0);
let filled = Node::path(d.alloc_id(), rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), checks);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, filled).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let frame = |r: &mut Renderer, d: &Document| r.render(d, 200, 100, Affine::IDENTITY, &opts).pixels;
let mut r = Renderer::new();
let before = frame(&mut r, &d);
session().execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
let warm = frame(&mut r, &d);
let fresh = std::thread::scope(|t| t.spawn(|| frame(&mut Renderer::new(), &d)).join().unwrap());
cms::set_active(&old).unwrap();
assert!(fresh != before, "the new profile changes the frame");
assert!(warm == fresh, "drawn as a renderer with no tiles draws it");
}
/// Live objects follow the Color Settings: in the next frame, a blend of an RGB red and a CMYK cyan,
/// the same blend in an envelope, an envelope that distorts the appearance of a CMYK cyan rectangle
/// with Revolve, and a gradient mesh in CMYK colors draw through the new working CMYK profile, and
+530 -4
View File
@@ -538,17 +538,29 @@ proptest! {
// ---------- PDF ----------
fn handmade_pdf(content: &str, resources: &str, media: &str) -> Vec<u8> {
let objs = [
handmade_pdf_with(content, resources, media, &[])
}
/// The same, followed by the objects `more` (numbered from 5).
fn handmade_pdf_with(content: &str, resources: &str, media: &str, more: &[Vec<u8>]) -> Vec<u8> {
let page = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox {media} /Contents 4 0 R /Resources {resources} >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
];
pdf_of(&page.into_iter().map(String::into_bytes).chain(more.iter().cloned()).collect::<Vec<_>>())
}
/// The PDF of the objects `objs` (numbered from 1, the catalog first).
fn pdf_of(objs: &[Vec<u8>]) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![];
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
@@ -2157,10 +2169,15 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
/// The testkit's sample editing data, compressed as older `.ai` files compress it.
fn ai_sample_compressed() -> Vec<u8> {
[&b"%AI12_CompressedData"[..], &deflated(&vectorcraft_testkit::ai::sample_data())].concat()
}
/// `data` compressed (zlib).
fn deflated(data: &[u8]) -> Vec<u8> {
use std::io::Write as _;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(&vectorcraft_testkit::ai::sample_data()).unwrap();
[&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat()
e.write_all(data).unwrap();
e.finish().unwrap()
}
proptest! {
@@ -2533,6 +2550,333 @@ fn deep_pdf(content: &str, resources: &str) -> Vec<u8> {
handmade_pdf(content, resources, "[0 0 100 100]")
}
/// A stream object with the entries `dict`, its `data` compressed.
fn packed(dict: &str, data: &str) -> Vec<u8> {
packed_as_is(dict, &deflated(data.as_bytes()))
}
/// A stream object with the entries `dict`, its data `z` compressed already.
fn packed_as_is(dict: &str, z: &[u8]) -> Vec<u8> {
// (A length the test gives stands.)
let length = if dict.contains("/Length") { String::new() } else { format!(" /Length {}", z.len()) };
[format!("<< {dict} /Filter /FlateDecode{length} >>\nstream\n").as_bytes(), z, b"\nendstream"].concat()
}
/// A page whose content is `content`, with `resources` and the objects `more` (numbered from 5).
fn pdf_with(content: &str, resources: &str, more: &[Vec<u8>]) -> Vec<u8> {
handmade_pdf_with(content, resources, "[0 0 100 100]", more)
}
/// Arrays in arrays, and dictionaries in dictionaries, far deeper than any reader goes: as the
/// operands of content.
fn nested_operands() -> [String; 2] {
let (n, d) = (100_000, 50_000);
[format!("BT {}{} TJ ET 0 0 5 5 re f", "[".repeat(n), "]".repeat(n)), format!("/P {}{} DP 0 0 5 5 re f", "<< /A ".repeat(d), ">> ".repeat(d))]
}
/// A page drawing through the compressed stream that `container` makes of each of
/// [`nested_operands`] (object 5), one file each.
fn nested_in(content: &str, resources: &str, container: impl Fn(&str) -> Vec<Vec<u8>>) -> Vec<Vec<u8>> {
nested_operands().iter().map(|deep| pdf_with(content, resources, &container(deep))).collect()
}
const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]";
/// A Type 3 font (object 5) whose glyph `a` is object 6.
fn type3_font(resources: &str) -> Vec<u8> {
format!(
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /a 6 0 R >> \
/Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] /Resources {resources} >>"
)
.into_bytes()
}
/// A page whose box is object 6, kept with object 7 (a number) in a compressed object stream
/// (object 5, with the entries `stream`): a file with a cross-reference stream, as it must be.
fn pdf_with_object_stream(boxed: &str, stream: &str) -> Vec<u8> {
let content = "0 0 5 5 re f";
let inside = format!("{boxed} 77");
let index = format!("6 0 7 {} ", boxed.len() + 1);
let objs = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox 6 0 R /Contents 4 0 R /Resources << >> >>".to_vec(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes(),
packed(&format!("/Type /ObjStm /N 2 /First {} {stream}", index.len()), &format!("{index}{inside}")),
];
let mut out = b"%PDF-1.7\n".to_vec();
// One entry an object: its kind, then where it is (an offset, or the stream it is in and its
// place there).
let mut entries = vec![[0u8, 0, 0, 0, 0, 255, 255]];
let at = |kind: u8, a: usize, b: u16| {
let (a, b) = ((a as u32).to_be_bytes(), b.to_be_bytes());
[kind, a[0], a[1], a[2], a[3], b[0], b[1]]
};
for (i, o) in objs.iter().enumerate() {
entries.push(at(1, out.len(), 0));
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
entries.extend([at(2, 5, 0), at(2, 5, 1), at(1, out.len(), 0)]);
let xref = out.len();
out.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 9 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", entries.len() * 7).as_bytes());
out.extend_from_slice(&entries.concat());
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes());
out
}
/// `n` objects from object 5 on, each made by `link` of the number of the next, then `last`.
fn chain(n: usize, link: impl Fn(usize) -> String, last: &str) -> Vec<Vec<u8>> {
(0..n).map(|i| link(i + 6).into_bytes()).chain([last.as_bytes().to_vec()]).collect()
}
/// A page painting with the shading whose function is object 5.
fn pdf_with_function(more: &[Vec<u8>]) -> Vec<u8> {
let shading = "/Shading << /S << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 5 0 R >> >>";
pdf_with("/S sh", &format!("<< {shading} >>"), more)
}
/// A page filling in the colour space that is object 5.
fn pdf_with_color_space(more: &[Vec<u8>]) -> Vec<u8> {
pdf_with("/C cs 0 sc 0 0 5 5 re f /C CS 0 SC 0 0 5 5 re S", "<< /ColorSpace << /C 5 0 R >> >>", more)
}
/// Every hostile PDF here opens as an error or as a document within the cap, on a small stack.
fn open_each(name: &str, files: impl IntoIterator<Item = Vec<u8>> + Send + 'static) {
let name = name.to_string();
on_a_small_stack(move || {
for (i, bytes) in files.into_iter().enumerate() {
open_deep(&format!("{name}-{i}.pdf"), &bytes);
// As `.ai` (the PDF part of one) and placed, too.
open_deep(&format!("{name}-{i}.ai"), &bytes);
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let _ = no_panic(s.execute("file.place", &json!({"name": "deep.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})));
assert!(!s.doc().unwrap().doc.too_deep(None));
}
});
}
// Nesting where only the reader of the decoded data sees it: inside each kind of compressed
// stream a page draws through.
#[test]
fn pdf_nesting_inside_a_compressed_form() {
open_each("form", nested_in("/F Do", "<< /XObject << /F 5 0 R >> >>", |deep| vec![packed(FORM, deep)]));
}
#[test]
fn pdf_nesting_inside_a_compressed_pattern() {
let tile = "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>";
open_each("pattern", nested_in("/Pattern cs /P scn 0 0 50 50 re f", "<< /Pattern << /P 5 0 R >> >>", |deep| vec![packed(tile, deep)]));
}
#[test]
fn pdf_nesting_inside_a_compressed_glyph() {
let glyph = |deep: &str| vec![type3_font("<< >>"), packed("", &format!("10 0 d0 {deep}"))];
open_each("glyph", nested_in("BT /F1 12 Tf 10 10 Td (aa) Tj ET", "<< /Font << /F1 5 0 R >> >>", glyph));
}
#[test]
fn pdf_nesting_inside_a_compressed_soft_mask() {
let group = format!("{FORM} /Group << /S /Transparency /CS /DeviceGray >>");
let state = "<< /ExtGState << /G << /Type /ExtGState /SMask << /S /Luminosity /G 5 0 R >> >> >> >>";
open_each("mask", nested_in("/G gs 0 0 50 50 re f", state, move |deep| vec![packed(&group, deep)]));
}
#[test]
fn pdf_nesting_inside_a_compressed_annotation() {
let annots = "<< >> /Annots [<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /AP << /N 5 0 R >> >>]";
open_each("annotation", nested_in("0 0 5 5 re f", annots, |deep| vec![packed(FORM, deep)]));
}
#[test]
fn pdf_nesting_inside_compressed_page_content() {
let files = nested_operands().map(|deep| {
let mut objs: Vec<Vec<u8>> = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"].map(Vec::from).into();
objs.push(b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents [4 0 R 4 0 R] /Resources << >> >>".to_vec());
objs.push(packed("", &deep));
pdf_of(&objs)
});
open_each("content", files);
}
#[test]
fn pdf_nesting_inside_an_object_stream() {
let (n, d) = (100_000, 50_000);
open_each(
"objects",
[
// A plain one first: the file is read as it should be.
pdf_with_object_stream("[0 0 100 100]", ""),
pdf_with_object_stream(&format!("{}0 0 100 100{}", "[".repeat(n), "]".repeat(n)), ""),
pdf_with_object_stream(&format!("{}{}", "<< /A ".repeat(d), ">> ".repeat(d)), ""),
// Its length is an object kept in the stream itself.
pdf_with_object_stream("[0 0 100 100]", "/Length 7 0 R"),
// It extends itself.
pdf_with_object_stream("[0 0 100 100]", "/Extends 5 0 R"),
],
);
let plain = vectorcraft_engine::cmd::fileio::load("plain.pdf", &pdf_with_object_stream("[0 0 120 80]", "")).expect("an object stream is read");
assert_eq!(plain.doc.artboards[0].rect.width(), 120.0);
}
// What refers to itself, or to the next of its kind without end.
#[test]
fn pdf_page_trees_that_hold_themselves() {
let page = |parent: usize| format!("<< /Type /Page /Parent {parent} 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << >> >>");
let content = "<< /Length 13 >>\nstream\n0 0 5 5 re f\nendstream";
let own = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [2 0 R 3 0 R 2 0 R] /Count 1 >>".to_string(),
page(2),
content.to_string(),
];
// Thousands of nodes, each holding the next; the page is the last.
let n = 30_000;
let mut deep = vec!["<< /Type /Catalog /Pages 5 0 R >>".to_string(), "null".to_string(), "null".to_string(), content.to_string()];
deep.extend((0..n).map(|i| format!("<< /Type /Pages /Kids [{} 0 R] /Count 1 >>", i + 6)));
deep.push(page(n + 4));
// Two nodes holding each other.
let pair = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [5 0 R] /Count 1 >>".to_string(),
page(5),
content.to_string(),
"<< /Type /Pages /Kids [2 0 R 3 0 R] /Count 1 >>".to_string(),
];
let bytes = |objs: &[String]| pdf_of(&objs.iter().map(|o| o.clone().into_bytes()).collect::<Vec<_>>());
open_each("pages", [bytes(&own), bytes(&deep), bytes(&pair)]);
}
#[test]
fn pdf_functions_that_hold_themselves() {
let stitch = |next: usize| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{next} 0 R] /Bounds [] /Encode [0 1] >>");
let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>";
let calculator = "/FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1]";
let n = 100_000;
open_each(
"functions",
[
pdf_with_function(&[stitch(5).into_bytes()]),
pdf_with_function(&[stitch(6).into_bytes(), stitch(5).into_bytes()]),
pdf_with_function(&chain(20_000, stitch, ramp)),
// A calculator function of procedures in procedures.
pdf_with_function(&[packed(calculator, &format!("{}{}", "{".repeat(n), "}".repeat(n)))]),
pdf_with_function(&[packed(calculator, &format!("{{ {} 0 0 0 {} }}", "true {".repeat(n), "} if ".repeat(n)))]),
],
);
}
#[test]
fn pdf_color_spaces_that_hold_themselves() {
let indexed = |next: usize| format!("[/Indexed {next} 0 R 1 <000000ffffff>]");
let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 0] /N 1 >>";
let profile = |alternate: &str| format!("<< /N 3 /Alternate {alternate} /Length 4 >>\nstream\njunk\nendstream").into_bytes();
open_each(
"spaces",
[
pdf_with_color_space(&[indexed(5).into_bytes()]),
pdf_with_color_space(&[indexed(6).into_bytes(), indexed(5).into_bytes()]),
pdf_with_color_space(&chain(20_000, indexed, "/DeviceRGB")),
pdf_with_color_space(&[b"[/Separation /Ink 5 0 R 6 0 R]".to_vec(), tint.as_bytes().to_vec()]),
pdf_with_color_space(&[b"[/DeviceN [/Ink] 5 0 R 6 0 R]".to_vec(), tint.as_bytes().to_vec()]),
pdf_with_color_space(&[b"[/Pattern 5 0 R]".to_vec()]),
pdf_with_color_space(&[b"[/ICCBased 6 0 R]".to_vec(), profile("5 0 R")]),
pdf_with_color_space(&[b"[/ICCBased 6 0 R]".to_vec(), profile("[/ICCBased 6 0 R]")]),
// As the space of a shading and of an image.
pdf_with(
"/S sh",
"<< /Shading << /S << /ShadingType 2 /ColorSpace 5 0 R /Coords [0 0 9 0] /Function 6 0 R >> >> >>",
&[indexed(5).into_bytes(), tint.as_bytes().to_vec()],
),
pdf_with(
"q 50 0 0 50 0 0 cm /I Do Q",
"<< /XObject << /I 6 0 R >> >>",
&[
indexed(5).into_bytes(),
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace 5 0 R /Length 1 >>\nstream\n0\nendstream"
.to_vec(),
],
),
],
);
}
/// Spaces past what a stream may decode to (256 MB), compressed (about 260 kB).
fn spaces_past_the_cap() -> Vec<u8> {
use std::io::Write as _;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
for _ in 0..257 {
e.write_all(&[b' '; 1 << 20]).unwrap();
}
e.finish().unwrap()
}
// Streams the reader would fail on (#1700, #1701): it applies a predictor before checking its
// parameters (`Columns 0` divides by zero, a huge `Columns` allocates a row that large), and
// inflates without a ceiling.
#[test]
fn pdf_streams_the_reader_would_fail_on_are_refused() {
let zero = "/DecodeParms << /Predictor 2 /Columns 0 >>";
let huge = "/DecodeParms << /Predictor 12 /Colors 4 /Columns 99999999999 >>";
// An inline image whose data is deflated, written in hexadecimal so the content stays text.
let inline = |dp: &str| {
let data: String = deflated(&[0; 8]).iter().map(|b| format!("{b:02x}")).collect();
format!("q 10 0 0 10 0 0 cm BI /W 2 /H 2 /CS /G /BPC 8 /F [/AHx /Fl] /DP [null {dp}] ID {data}> EI Q")
};
let form = |dict: &str, content: &str| pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed(&format!("{FORM} {dict}"), content)]);
let files = [
("a form with Columns 0", form(zero, "0 0 5 5 re f")),
("a form with a huge Columns", form(huge, "0 0 5 5 re f")),
("an inline image with Columns 0", deep_pdf(&inline("<< /Predictor 2 /Columns 0 >>"), "<< >>")),
("an inline image with a huge Columns", deep_pdf(&inline("<< /Predictor 15 /Columns 99999999999 >>"), "<< >>")),
("an inline image in a compressed form", form("", &inline("<< /Predictor 2 /Columns 0 >>"))),
("an object stream with Columns 0", pdf_with_object_stream("[0 0 100 100]", zero)),
("a form of spaces past the cap", pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed_as_is(FORM, &spaces_past_the_cap())])),
];
on_a_small_stack(move || {
for (what, bytes) in &files {
for name in ["hostile.pdf", "hostile.ai"] {
let e = vectorcraft_engine::cmd::fileio::load(name, bytes).err().map(|e| e.to_string());
assert!(e.as_deref().is_some_and(|e| e.contains("would decode to more than 256 MB")), "{what} as {name}: {e:?}");
}
}
});
// Within range, the same files open.
let fine = "/DecodeParms << /Predictor 1 /Columns 0 >>";
for bytes in [form(fine, "0 0 5 5 re f"), deep_pdf(&inline("<< /Predictor 1 >>"), "<< >>")] {
vectorcraft_engine::cmd::fileio::load("fine.pdf", &bytes).expect("opens");
}
}
#[test]
fn pdf_art_that_draws_itself() {
let own = "<< /XObject << /F 5 0 R >> /Pattern << /P 5 0 R >> /Font << /F1 5 0 R >> /ExtGState << /G << /SMask << /S /Alpha /G 5 0 R >> >> >> >>";
let tile = format!("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources {own}");
let group = format!("{FORM} /Group << /S /Transparency >> /Resources {own}");
open_each(
"itself",
[
// A form drawing itself twice, a pattern painting its own tile, a glyph showing
// itself, and a mask masked by itself.
pdf_with("/F Do", own, &[packed(&format!("{FORM} /Resources {own}"), "0 0 5 5 re f /F Do 1 0 0 1 3 3 cm /F Do")]),
pdf_with("/Pattern cs /P scn 0 0 50 50 re f", own, &[packed(&tile, "/Pattern cs /P scn 0 0 5 5 re f")]),
pdf_with("BT /F1 12 Tf (a) Tj ET", own, &[type3_font(own), packed("", "10 0 d0 0 0 5 5 re f BT /F1 1 Tf (a) Tj ET")]),
pdf_with("/G gs 0 0 50 50 re f", own, &[packed(&group, "/G gs 0 0 5 5 re f")]),
// An image that is its own mask, and objects that are only a reference to each other.
pdf_with("q 50 0 0 50 0 0 cm /F Do Q", own, &[
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 5 0 R /Length 1 >>\nstream\n0\nendstream".to_vec(),
]),
handmade_pdf_with("0 0 5 5 re f", "5 0 R", "6 0 R", &[b"6 0 R".to_vec(), b"5 0 R".to_vec()]),
handmade_pdf_with("0 0 5 5 re f", "<< >>", "[0 0 9 9]", &[b"<< /Length 5 0 R >>\nstream\nx\nendstream".to_vec()]),
],
);
}
/// An EPS whose program is `body`.
fn deep_eps(body: &str) -> Vec<u8> {
format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n").into_bytes()
@@ -2589,6 +2933,188 @@ fn deeply_nested_dxf_blocks_are_read_within_the_cap() {
});
}
#[test]
fn dxf_blocks_inserting_themselves_are_left_out() {
on_a_small_stack(|| {
let pair = |c: i32, v: &str| (c, v.to_string());
// A line, then an insert of `inserts`.
let block = |name: &str, inserts: &str| {
let line = [pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "5"), pair(21, "5")];
let insert = [pair(0, "INSERT"), pair(2, inserts), pair(10, "1"), pair(20, "1"), pair(0, "ENDBLK")];
[pair(0, "BLOCK"), pair(2, name), pair(10, "0"), pair(20, "0")].into_iter().chain(line).chain(insert).collect::<Vec<_>>()
};
let mut pairs = vec![pair(0, "SECTION"), pair(2, "BLOCKS")];
pairs.extend([block("SELF", "SELF"), block("A", "B"), block("B", "A")].concat());
pairs.extend([pair(0, "ENDSEC"), pair(0, "SECTION"), pair(2, "ENTITIES")]);
for name in ["SELF", "A"] {
pairs.extend([pair(0, "INSERT"), pair(2, name), pair(10, "0"), pair(20, "0")]);
}
pairs.extend([pair(0, "ENDSEC"), pair(0, "EOF")]);
let (_, warnings) = open_deep("self.dxf", dxf_text(&pairs).as_bytes()).expect("opens");
assert!(warnings.iter().any(|w| w.contains("inside themselves")), "{warnings:?}");
});
}
#[test]
fn deeply_nested_affinity_groups_are_read_within_the_cap() {
use vectorcraft_affinity::synth::{self, F, Method, tag};
// Groups in groups: within what the reader takes, past its cap on layers (128), past its cap
// on the stream's own nesting (384), and far past both.
let files: Vec<Vec<u8>> = [20u32, 120, 180, 370, 2000]
.into_iter()
.map(|levels| {
let group = |id: u32, fields| F::Def(id, vec![tag(b"Grup")], fields);
let inner = (0..levels).fold(group(10, vec![]), |inner, i| group(11 + i, vec![(tag(b"Chld"), F::Shared(vec![inner]))]));
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![inner]))]);
let stream = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
affinity_native(&stream, Method::Stored)
})
.collect();
on_a_small_stack(move || {
for bytes in &files {
open_deep("deep.afdesign", bytes);
}
});
}
#[test]
fn metafiles_saving_states_without_end_are_read_within_the_cap() {
on_a_small_stack(|| {
// A saved state, a clip and a rectangle, over and over (nothing is ever restored).
let longs = |v: [i32; 4]| v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>();
let emf: Vec<(u32, Vec<u8>)> = (0..50_000).flat_map(|_| [(33, vec![]), (30, longs([0, 0, 500, 500])), (43, longs([0, 0, 50, 50]))]).collect();
open_deep("deep.emf", &emf_of(&emf));
let shorts = |v: [i16; 4]| v.iter().flat_map(|x| x.to_le_bytes()).collect::<Vec<u8>>();
let wmf: Vec<(u16, Vec<u8>)> =
(0..20_000).flat_map(|_| [(0x001E, vec![]), (0x0416, shorts([500, 500, 0, 0])), (0x041B, shorts([50, 50, 0, 0]))]).collect();
open_deep("deep.wmf", &wmf_of(&wmf));
});
}
#[test]
fn deeply_nested_plugin_manifests_and_output_are_errors() {
on_a_small_stack(|| {
// (A manifest may be 64 kB.)
let deep = |n: usize| format!("{}{}", "[".repeat(n), "]".repeat(n));
assert!(vectorcraft_plugins::Manifest::parse(deep(30_000).as_bytes()).is_err());
assert!(vectorcraft_plugins::Manifest::parse(format!("{{\"params\": {}}}", deep(30_000)).as_bytes()).is_err());
let deep = deep(100_000);
for output in [deep.clone(), format!("{{\"objects\": {deep}}}"), format!("{{\"objects\": [{{\"kind\": \"path\", \"path\": {deep}}}]}}")] {
assert!(vectorcraft_plugins::objects::decode(output.as_bytes()).is_err());
}
});
}
/// A document whose symbol `s0` and pattern `p0` are each a line stroked with the brush `loop`,
/// with an instance of the symbol and a square painted with the pattern on the layer. The brush
/// is the `kind`th of those that hold art (art, scatter, pattern), and its art is the `art`th of:
/// an instance of the symbol; that instance inside as many groups as a file holds; a square
/// painted with the pattern. So what the brush draws is drawn with the brush.
fn brushed_document(kind: usize, art: usize) -> Document {
use vectorcraft_brush::{ArtBrush, Brush, BrushKind, PatternBrush, Scatter};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, PatternDef, Symbol};
let mut doc = nested_document(1);
let square = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 60.0, 20.0));
let brushed = |doc: &mut Document| {
let mut look = Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0)), 4.0);
for item in &mut look.items {
if let AppearanceItem::Stroke(s) = item {
s.brush = Some("loop".into());
}
}
Node::path(doc.alloc_id(), square(), look)
};
let instance =
|doc: &mut Document| Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "s0".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let painted = |doc: &mut Document| {
let fill = Paint::Pattern { pattern: "p0".into(), xf: vectorcraft_geom::Affine::IDENTITY };
Node::path(doc.alloc_id(), square(), Appearance::basic(fill, Paint::None, 0.0))
};
let (in_symbol, in_pattern) = (brushed(&mut doc), brushed(&mut doc));
doc.symbols.push(Symbol { name: "s0".into(), art: std::sync::Arc::new(in_symbol) });
doc.patterns.push(PatternDef::new("p0", vec![std::sync::Arc::new(in_pattern)]));
let art = match art {
0 => instance(&mut doc),
1 => (0..MAX_DEPTH + 4).fold(instance(&mut doc), |inner, _| Node::group(doc.alloc_id(), vec![std::sync::Arc::new(inner)])),
_ => painted(&mut doc),
};
let kind = match kind {
0 => BrushKind::Art(ArtBrush { art, ..Default::default() }),
1 => BrushKind::Scatter(Scatter { art, ..Default::default() }),
_ => BrushKind::Pattern(PatternBrush { side: art.clone(), start: Some(art), ..Default::default() }),
};
vectorcraft_brush::store(&mut doc, &[Brush { name: "loop".into(), kind }]);
let layer = doc.layers[0].id;
for on_layer in [instance(&mut doc), painted(&mut doc), brushed(&mut doc)] {
doc.insert(Some(layer), usize::MAX, on_layer).unwrap();
}
doc
}
/// `doc` saved, opened again, drawn, exported and edited: nothing overflows.
fn art_by_name_survives(what: &str, doc: Document) {
let (back, _) = open_deep("named.vectorcraft", &vectorcraft_format::save_file(&doc)).expect("opens");
let mut s = vectorcraft_engine::Session::new();
s.add_document(back, None);
for (cmd, p) in [
("select.all", json!({})),
("document.inspect", json!({})),
("brush.expand", json!({})),
("edit.undo", json!({})),
("object.expand", json!({})),
("edit.undo", json!({})),
("object.outlineStroke", json!({})),
("edit.undo", json!({})),
("graph.design", json!({"paste": "deep"})),
("graph.create", json!({"type": "line", "x": 10, "y": 10, "width": 80, "height": 60})),
("graph.marker", json!({"design": "deep"})),
("graph.design", json!({"new": "again"})),
("edit.copy", json!({})),
("edit.paste", json!({})),
] {
let _ = no_panic(s.execute(cmd, &p));
assert!(!s.doc().unwrap().doc.too_deep(None), "{what}: {cmd}");
}
for format in ["svg", "pdf", "eps", "png", "dxf", "emf"] {
let _ = no_panic(s.execute("document.export", &json!({"format": format})));
}
}
/// A test for each brush whose art is drawn with the brush itself ([`brushed_document`]).
macro_rules! brushes_drawn_with_themselves {
($($test:ident: $kind:literal, $art:literal;)*) => {$(
#[test]
fn $test() {
on_a_small_stack(|| art_by_name_survives(stringify!($test), brushed_document($kind, $art)));
}
)*};
}
brushes_drawn_with_themselves! {
an_art_brush_of_its_own_symbol: 0, 0;
an_art_brush_of_its_own_symbol_in_deep_art: 0, 1;
an_art_brush_painted_with_its_own_pattern: 0, 2;
a_scatter_brush_of_its_own_symbol: 1, 0;
a_scatter_brush_of_its_own_symbol_in_deep_art: 1, 1;
a_scatter_brush_painted_with_its_own_pattern: 1, 2;
a_pattern_brush_of_its_own_symbol: 2, 0;
a_pattern_brush_of_its_own_symbol_in_deep_art: 2, 1;
a_pattern_brush_painted_with_its_own_pattern: 2, 2;
}
#[test]
fn a_graph_design_of_deep_art_and_of_a_symbol_holding_itself_is_drawn() {
use vectorcraft_doc::{GraphDesign, Node, NodeKind};
on_a_small_stack(|| {
let mut doc = symbols_document(1, 1, "s0");
let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "s0".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let art = (0..MAX_DEPTH + 4).fold(instance, |inner, _| Node::group(doc.alloc_id(), vec![std::sync::Arc::new(inner)]));
doc.graph_designs.push(GraphDesign { name: "deep".into(), art: std::sync::Arc::new(art) });
art_by_name_survives("a graph design of deep art", doc);
});
}
/// A layer holding `levels` groups in one another around a rectangle, each with a second
/// rectangle beside the next: `levels + 2` deep.
fn nested_document(levels: usize) -> Document {
+38
View File
@@ -121,6 +121,44 @@ fn batch_is_one_undo_step_and_rolls_back_on_error() {
assert_eq!(s.doc().unwrap().doc.node_count(), 1);
}
/// #1697: a failed batch brings back the whole document, not only its art: the file a Save As
/// step named and the history an Undo step moved; and a batch leaves a drag in progress its own
/// step.
#[test]
fn a_failed_batch_restores_the_file_the_history_and_a_drag_in_progress() {
let mut s = fixtures::session();
let rect = |x: i32| json!({"command": "shape.rectangle", "params": {"x": x, "y": 0, "width": 10, "height": 10}});
let fail = json!({"command": "no.such.command", "params": {}});
exec(&mut s, "shape.rectangle", json!({"x": 50, "y": 0, "width": 10, "height": 10}));
let before = (doc_json(&s.doc().unwrap().doc), s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len());
let state = |s: &vectorcraft_engine::Session| {
let st = s.doc().unwrap();
(doc_json(&st.doc), st.history.undo.len(), st.history.redo.len())
};
// Save As, then a failing step: the document keeps no file and stays modified.
let path = vectorcraft_testkit::temp_dir("batch-rollback").join("named.vectorcraft").to_string_lossy().to_string();
let save = json!({"command": "file.saveAs", "params": {"path": path}});
assert!(s.execute("command.batch", &json!({"commands": [rect(0), save, fail]})).is_err());
assert_eq!(state(&s), before);
assert_eq!((s.doc().unwrap().path.as_deref(), s.doc().unwrap().is_dirty()), (None, true));
// Undo, then a failing step: the art and both history stacks are as before.
let undo = json!({"command": "edit.undo", "params": {}});
assert!(s.execute("command.batch", &json!({"commands": [undo, fail]})).is_err());
assert_eq!(state(&s), before);
// A batch during a drag: the drag is kept as a step, the batch is the next one.
exec(&mut s, "select.all", json!({}));
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
let moved = doc_json(&s.doc().unwrap().doc);
exec(&mut s, "command.batch", json!({"commands": [rect(100)]}));
assert!(!s.in_interaction());
assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 2);
exec(&mut s, "edit.undo", json!({}));
assert_eq!(doc_json(&s.doc().unwrap().doc), moved, "undoing the batch leaves the drag's move");
exec(&mut s, "edit.undo", json!({}));
assert_eq!(doc_json(&s.doc().unwrap().doc), before.0);
}
#[test]
fn empty_batch_is_harmless() {
let mut s = fixtures::session();
+6
View File
@@ -29,6 +29,8 @@ pub(crate) const MAX_GSAVE: usize = 1000;
const MAX_MEMORY: usize = 1 << 30;
pub(crate) struct Interp<'a> {
/// The list of the arrays and dictionaries made since this started ([`super::obj::track`]).
made: u64,
pub lex: Lexer<'a>,
pub stack: Vec<Obj>,
pub dicts: Vec<DictRef>,
@@ -101,6 +103,7 @@ const CATEGORIES: &str = "Category Generic Font CIDFont CMap FontSet Encoding Fo
impl<'a> Interp<'a> {
pub fn new(src: &'a [u8], g: GState, out: Out) -> Self {
let made = super::obj::track();
let system = new_dict();
{
let mut d = system.borrow_mut();
@@ -140,6 +143,7 @@ impl<'a> Interp<'a> {
system.borrow_mut().insert(Key::name(name), Obj::Dict(d.clone()));
}
Self {
made,
lex: Lexer::new(src),
stack: vec![],
dicts: vec![system.clone(), global.clone(), user.clone()],
@@ -205,6 +209,8 @@ impl<'a> Interp<'a> {
self.dicts.clear();
self.stack.clear();
self.journal.clear();
// What the program linked into itself, reachable or not.
super::obj::release_tracked(self.made);
}
// ---------- the operand stack ----------
+95 -3
View File
@@ -3,7 +3,7 @@
use std::cell::{Ref, RefCell};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::rc::Rc;
use std::rc::{Rc, Weak};
use super::data::Stream;
use super::graphics::GState;
@@ -17,10 +17,95 @@ pub(crate) struct DictRef(Rc<RefCell<Dict>>);
impl DictRef {
pub fn new(d: Dict) -> Self {
Self(Rc::new(RefCell::new(d)))
let d = Rc::new(RefCell::new(d));
note(Made::Dict(Rc::downgrade(&d)));
Self(d)
}
}
/// An array or a dictionary a program made.
enum Made {
Array(Weak<RefCell<Vec<Obj>>>),
Dict(Weak<RefCell<Dict>>),
}
/// The arrays and dictionaries made on this thread since [`track`], for [`release_tracked`].
struct Tracked {
owner: u64,
made: Vec<Made>,
/// The list is cleared of dropped objects when it reaches this length.
sweep_at: usize,
}
const SWEEP_MIN: usize = 4096;
#[cfg(test)]
thread_local! {
/// Tests: the arrays of the list last released on this thread that were still alive after it
/// (the ones the interpreter itself still held).
pub(crate) static ARRAYS_LEFT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
thread_local! {
static TRACKED: RefCell<Option<Tracked>> = const { RefCell::new(None) };
}
/// Start listing the arrays and dictionaries made on this thread (an interpreter starting) → the
/// list's owner, for [`release_tracked`]. A list already there is dropped: its objects are freed
/// as before this existed, unless they hold each other.
pub(crate) fn track() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let owner = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// (Nothing to list on when the thread is ending.)
let _ = TRACKED.try_with(|t| t.try_borrow_mut().map(|mut t| *t = Some(Tracked { owner, made: vec![], sweep_at: SWEEP_MIN })));
owner
}
fn note(m: Made) {
let _ = TRACKED.try_with(|t| {
let Ok(mut t) = t.try_borrow_mut() else { return };
let Some(t) = t.as_mut() else { return };
if t.made.len() >= t.sweep_at {
// Twice what is alive at most, so the list costs a program no more than its objects.
t.made.retain(|m| match m {
Made::Array(a) => a.strong_count() > 0,
Made::Dict(d) => d.strong_count() > 0,
});
t.sweep_at = (t.made.len() * 2).max(SWEEP_MIN);
}
t.made.push(m);
});
}
/// Empty every array and dictionary still alive of the list `owner` started (the interpreter is
/// done with them), so those holding themselves or each other are freed too: reference counts
/// alone never free `/a 1 array def a 0 a put`, and a program can leave as much of that behind as
/// it may allocate, on every import (#1714).
pub(crate) fn release_tracked(owner: u64) {
let made = TRACKED.try_with(|t| {
let mut t = t.try_borrow_mut().ok()?;
t.take_if(|t| t.owner == owner).map(|t| t.made)
});
let made = made.ok().flatten().unwrap_or_default();
#[cfg(test)]
let arrays: Vec<_> = made.iter().filter_map(|m| if let Made::Array(a) = m { Some(a.clone()) } else { None }).collect();
for m in made {
// What it holds is taken out first and dropped with nothing borrowed.
match m {
Made::Array(a) => {
let held = a.upgrade().and_then(|a| a.try_borrow_mut().ok().map(|mut values| std::mem::take(&mut *values)));
drop_held(held.unwrap_or_default().into_iter());
}
Made::Dict(d) => {
let held = d.upgrade().and_then(|d| d.try_borrow_mut().ok().map(|mut d| std::mem::take(&mut *d)));
drop_held(held.unwrap_or_default().into_values());
}
}
}
#[cfg(test)]
ARRAYS_LEFT.with(|n| n.set(arrays.iter().filter(|a| a.strong_count() > 0).count()));
}
impl std::ops::Deref for DictRef {
type Target = Rc<RefCell<Dict>>;
fn deref(&self) -> &Self::Target {
@@ -70,6 +155,8 @@ fn drop_held(held: impl Iterator<Item = Obj>) {
pub(crate) trait Held: Sized {
/// Drop the values of storage nothing refers to any more.
fn release(_values: Vec<Self>) {}
/// Storage of these values was made.
fn made(_storage: &Rc<RefCell<Vec<Self>>>) {}
}
impl Held for u8 {}
@@ -78,6 +165,9 @@ impl Held for Obj {
fn release(values: Vec<Self>) {
drop_held(values.into_iter());
}
fn made(storage: &Rc<RefCell<Vec<Self>>>) {
note(Made::Array(Rc::downgrade(storage)));
}
}
/// A dictionary key.
@@ -219,7 +309,9 @@ impl<T: Held> Drop for Shared<T> {
impl<T: Clone + Held> Shared<T> {
pub fn new(v: Vec<T>) -> Self {
Self { len: v.len(), buf: Rc::new(RefCell::new(v)), at: 0 }
let (len, buf) = (v.len(), Rc::new(RefCell::new(v)));
T::made(&buf);
Self { len, buf, at: 0 }
}
pub fn len(&self) -> usize {
+17
View File
@@ -19,6 +19,23 @@ pub(super) fn read(body: &str) -> crate::Imported {
import(&eps(body)).unwrap()
}
/// #1714: arrays and dictionaries a program links into themselves or each other are freed with
/// the interpreter, also the ones it no longer reaches and when it stops on an error.
#[test]
fn arrays_and_dictionaries_holding_themselves_are_freed() {
// The arrays still alive once a program's interpreter has released what it made: the ones
// the interpreter itself holds, the same for every program here.
let left = |program: &str| {
let _ = import(&eps(program));
crate::import::obj::ARRAYS_LEFT.with(std::cell::Cell::get)
};
let plain = left("/a 1 array def /b 1 array def a 0 b put");
let cycles = "/a 1 array def a 0 a put /b 1 array def /c 1 array def b 0 c put c 0 b put /d 1 dict def /e 1 array def d /e e put e 0 d put 1 array dup 0 1 index put pop /a null def /b null def";
for program in [cycles.to_string(), format!("{cycles} 1 0 div"), format!("{cycles} /p {{ p }} def")] {
assert_eq!(left(&program), plain, "{program}");
}
}
/// The objects of the document's layer.
pub(super) fn objects(d: &Document) -> Vec<Arc<Node>> {
d.layers[0].children().unwrap().clone()
+16 -3
View File
@@ -181,13 +181,26 @@ fn push85(s: &mut String, mut v: u32, n: usize) {
}
}
/// ASCII85 `text` (whitespace ignored, up to `~>`) decoded; `None` when it is malformed.
/// Most bytes [`ascii85_decode`] makes: what one filter or data section may hold.
pub(crate) const MAX_ASCII85: usize = 1 << 28;
/// ASCII85 `text` (whitespace ignored, up to `~>`) decoded; `None` when it is malformed or longer
/// than [`MAX_ASCII85`].
pub(crate) fn ascii85_decode(text: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(text.len() * 4 / 5);
ascii85_decode_within(text, MAX_ASCII85)
}
/// [`ascii85_decode`] of at most `max` bytes: `z` is four bytes for one, so the length of the
/// text doesn't bound what it becomes (#1715).
pub(crate) fn ascii85_decode_within(text: &str, max: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity((text.len() / 5 * 4).min(max));
let mut group = [0u8; 5];
let mut n = 0;
let mut chars = text.bytes().filter(|b| !b.is_ascii_whitespace()).peekable();
while let Some(b) = chars.next() {
if out.len() > max {
return None;
}
match b {
b'~' => {
if chars.next() != Some(b'>') {
@@ -201,7 +214,7 @@ pub(crate) fn ascii85_decode(text: &str) -> Option<Vec<u8>> {
let v = value85(&padded)?;
out.extend(v.to_be_bytes().iter().take(n - 1));
}
return Some(out);
return (out.len() <= max).then_some(out);
}
b'z' if n == 0 => out.extend([0; 4]),
b'!'..=b'u' => {
+1 -1
View File
@@ -542,7 +542,7 @@ impl<'a> Scene<'a> {
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false };
let art = vectorcraft_brush::stroke_pieces(b, bp, st);
let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st);
self.note_item(st.opacity, st.blend);
self.nest += 1;
for piece in &art {
+5
View File
@@ -345,6 +345,11 @@ fn run_length_and_ascii85_round_trip() {
assert_eq!(ps::ascii85(b"Man "), "9jqo^~>\n");
assert_eq!(ps::ascii85(&[0, 0, 0, 0]), "z~>\n");
assert!(ps::ascii85_decode("9jqo^").is_none(), "no end marker");
// #1715: the result is bounded while it is made, not by the length of the text.
assert_eq!(ps::ascii85_decode_within("zz~>", 8), Some(vec![0; 8]));
assert!(ps::ascii85_decode_within("zzz~>", 8).is_none(), "three `z` are 12 bytes");
assert!(ps::ascii85_decode_within("zz9jqo^~>", 8).is_none());
assert!(ps::ascii85_decode_within(&format!("{}~>", "z".repeat(1 << 16)), 1 << 10).is_none());
assert!(ps::ascii85_decode("9jqo^v~>").is_none(), "not base 85");
assert_eq!(ps::num(1.0 / 3.0), "0.3333");
assert_eq!((ps::num(-0.00001), ps::num(f64::NAN), ps::num(2.5)), ("0".into(), "0".into(), "2.5".into()));
+88 -11
View File
@@ -4,7 +4,10 @@
//!
//! Where a folder refuses the temporary file or the rename (some network shares, a file another
//! program holds open), the new content is first written and flushed elsewhere and only then copied
//! into the file itself; if that copy fails, the temporary file stays and the error names it.
//! into the file itself. That copy isn't atomic, so the file as it was is copied and flushed beside
//! the temporary file first (#1695): if the copy into the file fails or is cut short, both versions
//! are on disk, and the error names them. Where that copy of the old file can't be made, the file
//! is left as it was and the save fails.
//!
//! On macOS `File::sync_all` is a full flush (`F_FULLFSYNC`), which SMB shares refuse with
//! `ENOTSUP` while a plain `fsync` works: [`sync_file`] falls back to the plain one there (#1620).
@@ -78,8 +81,10 @@ fn write_atomic_stepping(
/// The content of `tmp`, which is whole and flushed, copied into `target` itself, and `tmp`
/// removed: for a folder that `refused` the atomic replacement. When `target` can't be opened for
/// writing it is untouched: `tmp` is removed and `refused` returned. When the copy fails `target`
/// is damaged: `tmp` stays and the error says where it is.
/// writing it is untouched: `tmp` is removed and `refused` returned. When what it holds can't be
/// copied aside first ([`keep_old`]) it is untouched too: `tmp` stays and the error names it. When
/// the copy into `target` fails it is damaged: `tmp` and the copy of the old content stay and the
/// error says where they are.
fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> {
let opened = File::open(tmp).and_then(|from| Ok((from, OpenOptions::new().write(true).create(true).truncate(false).open(target)?)));
let Ok((mut from, mut to)) = opened else {
@@ -87,18 +92,42 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> {
let _ = std::fs::remove_file(tmp);
return Err(refused);
};
// Nothing to keep of a file that is new or empty.
let old = to.metadata().map_or(true, |m| m.len() > 0).then(|| tmp.with_extension("old"));
if let Some(old) = &old
&& let Err(e) = keep_old(target, old)
{
// Best effort: a partial copy is of no use.
let _ = std::fs::remove_file(old);
let why =
format!("{refused}, and the file can't be copied aside before it is overwritten ({e}); the new content is kept in {}", tmp.display());
return Err(io::Error::new(refused.kind(), why));
}
let copied = to.set_len(0).and_then(|()| io::copy(&mut from, &mut to)).and_then(|_| sync_file(&to));
drop((from, to));
match copied {
Ok(()) => {
// Best effort: the file is saved, so the copy is no longer needed.
match (copied, old) {
(Ok(()), old) => {
// Best effort: the file is saved, so the copies are no longer needed.
let _ = std::fs::remove_file(tmp);
if let Some(old) = old {
let _ = std::fs::remove_file(old);
}
Ok(())
}
Err(e) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))),
(Err(e), Some(old)) => {
Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display())))
}
(Err(e), None) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))),
}
}
/// A flushed copy of `target` as it is, at `old` (for its owner only).
fn keep_old(target: &Path, old: &Path) -> io::Result<()> {
let mut to = create_private(old)?;
io::copy(&mut File::open(target)?, &mut to)?;
sync_file(&to)
}
/// A new file for its owner only (Unix): the system's temporary folder is shared.
fn create_private(path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
@@ -185,8 +214,15 @@ fn plain_fsync(file: &File) -> io::Result<()> {
fn full_sync(file: &File) -> io::Result<()> {
#[cfg(test)]
if REFUSE_FULL_SYNC.with(std::cell::Cell::get) {
return Err(io::Error::from_raw_os_error(ENOTSUP));
{
if REFUSE_FULL_SYNC.with(std::cell::Cell::get) {
return Err(io::Error::from_raw_os_error(ENOTSUP));
}
match FLUSHES_LEFT.with(std::cell::Cell::get) {
Some(0) => return Err(io::Error::other("the connection was lost")),
Some(n) => FLUSHES_LEFT.with(|c| c.set(Some(n - 1))),
None => {}
}
}
file.sync_all()
}
@@ -203,6 +239,8 @@ thread_local! {
static REFUSE_FULL_SYNC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Tests: the plain `fsync` fallback fails too, with this OS error.
static PLAIN_FSYNC_FAILS: std::cell::Cell<Option<i32>> = const { std::cell::Cell::new(None) };
/// Tests: after this many more flushes on this thread, every flush fails.
static FLUSHES_LEFT: std::cell::Cell<Option<u32>> = const { std::cell::Cell::new(None) };
/// Tests: how many plain `fsync` fallbacks were made on this thread.
static PLAIN_FSYNCS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
@@ -363,8 +401,46 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// A refused rename and a copy into the file that fails (here: its flush): the file is damaged,
/// so the temporary file with the whole new content stays and the error names it.
/// #1695: a refused rename and a copy into the file that fails (here: its flush, after the copy
/// of the old content was flushed): the file is damaged, so the temporary file with the whole
/// new content and the copy of the old one stay, and the error names both.
#[test]
fn a_failed_copy_into_the_file_keeps_the_old_content_and_the_new() {
let d = dir("no-rename-copy-fails-late");
let path = d.join("doc.vectorcraft");
std::fs::write(&path, b"original").unwrap();
let r = write_atomic_stepping(
&path,
|f| f.write_all(b"new"),
create_new,
|_, _| {
// One more flush works (the copy of the old content), the next doesn't.
FLUSHES_LEFT.with(|c| c.set(Some(1)));
denied()
},
);
FLUSHES_LEFT.with(|c| c.set(None));
let message = r.unwrap_err().to_string();
let mut left = leftovers(&d, "doc.vectorcraft");
left.sort();
let [old, tmp] = left.as_slice() else { panic!("the old content and the new: {left:?}") };
assert!(old.ends_with(".old") && tmp.ends_with(".tmp"), "{left:?}");
assert!(message.contains(tmp.as_str()) && message.contains(old.as_str()), "{message}");
assert_eq!((std::fs::read(d.join(old)).unwrap(), std::fs::read(d.join(tmp)).unwrap()), (b"original".to_vec(), b"new".to_vec()));
// A new file has nothing to keep, and a save that works leaves neither copy.
std::fs::remove_file(d.join(old)).unwrap();
std::fs::remove_file(d.join(tmp)).unwrap();
let fresh = d.join("fresh.vectorcraft");
write_atomic_stepping(&fresh, |f| f.write_all(b"new"), create_new, |_, _| denied()).unwrap();
write_atomic_stepping(&fresh, |f| f.write_all(b"newer"), create_new, |_, _| denied()).unwrap();
assert_eq!(std::fs::read(&fresh).unwrap(), b"newer");
assert_eq!(leftovers(&d, "doc.vectorcraft"), ["fresh.vectorcraft"]);
let _ = std::fs::remove_dir_all(&d);
}
/// A refused rename where the old content can't be copied aside (here: no flush works): the
/// file is left as it was, the temporary file with the whole new content stays and the error
/// names it.
#[test]
fn a_failed_copy_into_the_file_keeps_the_temporary_file() {
let d = dir("no-rename-copy-fails");
@@ -388,6 +464,7 @@ mod tests {
let [tmp] = left.as_slice() else { panic!("one temporary file: {left:?}") };
assert!(message.contains(tmp.as_str()), "{message}");
assert_eq!(std::fs::read(d.join(tmp)).unwrap(), b"new");
assert_eq!(std::fs::read(&path).unwrap(), b"original", "not overwritten without a copy of it");
let _ = std::fs::remove_dir_all(&d);
}
+2 -1
View File
@@ -66,7 +66,8 @@ impl<W: Write> Write for Out<W> {
const OUT_CHUNK: usize = 64 << 10;
/// A gzip stream, written as [`flate2::write::GzEncoder`] at the default level writes it (the
/// same header, and the same bytes for a file without compressed images), except that data which
/// same header and the same data; the deflate blocks are the same too where flate2 deflates with
/// miniz_oxide, not with zlib-rs, which writes them by how the data is fed), except that data which
/// is compressed already ([`Gz::packed`]) is stored as it is: the default level spends a second
/// or more per photo on it for next to nothing, and makes it slower to unpack. Where the level
/// changes the deflate blocks so far end with a sync flush and a fresh compressor writes on,
+58 -4
View File
@@ -122,6 +122,11 @@ impl<'a> Unpacked<'a> {
/// data (entries sharing bytes, only in hand-made files, would copy them once each). Entries
/// are read in the order of their offsets, each straight into its own buffer.
pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result<Vec<Option<Vec<u8>>>, FormatError> {
self.entries_within(wanted, MAX_WORK)
}
/// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them.
fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result<Vec<Option<Vec<u8>>>, FormatError> {
let mut out: Vec<Option<Vec<u8>>> = vec![None; wanted.len()];
let in_memory = |tail: &[u8], out: &mut Vec<Option<Vec<u8>>>| {
for (slot, &[offset, len]) in out.iter_mut().zip(wanted) {
@@ -147,7 +152,7 @@ impl<'a> Unpacked<'a> {
drop(self.json);
let mut order: Vec<usize> = (0..wanted.len()).collect();
order.sort_by_key(|&i| wanted.get(i).map_or(0, |w| w[0]));
let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at };
let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at, left: work };
for i in order {
let Some(&[offset, len]) = wanted.get(i) else { continue };
if let Some(slot) = out.get_mut(i) {
@@ -155,6 +160,7 @@ impl<'a> Unpacked<'a> {
}
}
// To the end, so the stream's checksum is verified as before.
s.left = MAX_BLOBS;
s.skip(MAX_BLOBS)?;
}
}
@@ -188,12 +194,29 @@ struct Stream<'a> {
file: &'a [u8],
/// Where the data after the JSON starts in the unpacked stream.
at: u64,
/// Bytes that may still be unpacked ([`MAX_WORK`] in all): the entries' own, the gaps before
/// them, and the stream from its start again for each entry behind the one before.
left: u64,
}
/// Most bytes unpacked to read a streamed file's entries: the entries and the gaps between them
/// ([`MAX_BLOBS`] in a file a writer made, where they follow each other), and the JSON once more.
/// The entries' lengths alone don't bound it: one byte asked for at a far offset unpacks all
/// that lies before it, and each entry behind the last one starts the stream over (#1699).
const MAX_WORK: u64 = MAX_BLOBS + MAX_JSON;
impl Stream<'_> {
/// Count `n` bytes unpacked against the budget.
fn spend(&mut self, n: u64) -> Result<(), FormatError> {
self.left = self.left.checked_sub(n).ok_or(FormatError::TooBig)?;
Ok(())
}
/// Unpack and drop up to `n` bytes → how many there were.
fn skip(&mut self, n: u64) -> Result<u64, FormatError> {
let skipped = io::copy(&mut (&mut self.r).take(n), &mut io::sink()).map_err(damaged)?;
// One byte past the budget at most, so going over it is seen without doing the work.
let skipped = io::copy(&mut (&mut self.r).take(n.min(self.left.saturating_add(1))), &mut io::sink()).map_err(damaged)?;
self.spend(skipped)?;
self.pos += skipped;
Ok(skipped)
}
@@ -204,7 +227,8 @@ impl Stream<'_> {
// Entries out of order or overlapping (only hand-made files): unpack again from the
// start.
let mut dec = GzDecoder::new(self.file);
let skipped = io::copy(&mut (&mut dec).take(self.at), &mut io::sink()).map_err(damaged)?;
let skipped = io::copy(&mut (&mut dec).take(self.at.min(self.left.saturating_add(1))), &mut io::sink()).map_err(damaged)?;
self.spend(skipped)?;
if skipped < self.at {
return Ok(None);
}
@@ -219,7 +243,8 @@ impl Stream<'_> {
let n = usize::try_from(len).map_err(|_| FormatError::TooBig)?;
// The length is the file's word: reserve up to a bound, and let real data grow the rest.
bytes.try_reserve_exact(n.min(RESERVE_MAX)).map_err(|_| FormatError::TooBig)?;
let got = (&mut self.r).take(len).read_to_end(&mut bytes).map_err(damaged)?;
let got = (&mut self.r).take(len.min(self.left.saturating_add(1))).read_to_end(&mut bytes).map_err(damaged)?;
self.spend(got as u64)?;
self.pos += got as u64;
Ok((got as u64 == len).then_some(bytes))
}
@@ -237,6 +262,35 @@ mod tests {
gz.finish().unwrap()
}
/// #1699: reading a streamed file's entries unpacks a bounded number of bytes, whatever
/// their offsets: a far offset and entries behind one another are refused past the budget.
#[test]
fn a_streamed_files_entries_are_found_within_a_work_budget() {
let tail: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
let mut file = b"{}".to_vec();
file.extend(crate::BLOB_MAGIC);
file.extend(&tail);
let file = gzip(&file);
let read = |wanted: &[[u64; 2]], work: u64| {
let u = Unpacked::new(&file).unwrap();
assert!(matches!(u.tail, Tail::Stream { .. }), "read as a stream");
u.entries_within(wanted, work)
};
// Entries one after the other: the tail is unpacked once.
let parts = read(&[[0, 1000], [1000, 3096]], 4096).unwrap();
assert_eq!(parts, vec![Some(tail[..1000].to_vec()), Some(tail[1000..].to_vec())]);
// One byte at the far end is the whole tail's work.
assert_eq!(read(&[[4095, 1]], 4096).unwrap(), vec![Some(vec![tail[4095]])]);
assert!(matches!(read(&[[4095, 1]], 4000), Err(FormatError::TooBig)));
// Entries over the same bytes start the stream again each time: counted, then refused.
let same = vec![[4000, 96]; 50];
assert!(read(&same, 50 * (4096 + 64)).is_ok());
assert!(matches!(read(&same, 3 * 4096), Err(FormatError::TooBig)));
// An offset past the data is still just missing, and the default budget reads all of it.
assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]);
assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(tail.clone())]);
}
#[test]
fn entries_sharing_bytes_obey_the_limits_in_memory_too() {
// Each entry within MAX_BLOB, together past MAX_BLOBS: many references to the same bytes.
+9 -5
View File
@@ -1,7 +1,7 @@
//! Saving and opening as streams: the bytes a save writes are those an in-memory save wrote
//! before (the same JSON and data after it, unpacked; the same gzip bytes when no image is
//! compressed already), files written the old way still open, entries are read whatever their
//! order, and the limits apply per part of the file.
//! before (the same JSON and data after it, unpacked; the same gzip header), files written the old
//! way still open, entries are read whatever their order, and the limits apply per part of the
//! file.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -67,13 +67,17 @@ fn compressed_files_unpack_to_the_plain_file() {
}
}
/// The deflate bytes themselves depend on flate2's backend: zlib-rs (which hayro turns on) writes
/// other blocks for data fed in other pieces than the in-memory save fed it, miniz_oxide didn't.
#[test]
fn without_compressed_images_the_gzip_bytes_are_the_old_ones() {
fn without_compressed_images_the_gzip_stream_is_the_old_one() {
let mut d = doc_with(&[("scan", "image/tiff", INLINE_MAX * 2), ("icon", "image/png", 100)]);
d.title = "No photos".into();
for o in [SaveOptions::default(), SaveOptions { pdf: Some(vec![b'%'; INLINE_MAX]), ..SaveOptions::default() }] {
let plain = save_with(&d, &o).unwrap();
assert_eq!(save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain));
let (packed, old) = (save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain));
assert_eq!(packed.get(..10), old.get(..10), "the same header");
assert_eq!(gunzip(&packed), plain);
}
}
+1 -1
View File
@@ -403,7 +403,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 placed image, decoded once per image.
+1 -1
View File
@@ -20,7 +20,7 @@ pub use edit::{
};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, interior_point, live_paint, shape_builder};
pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, has_open_path, interior_point, live_paint, shape_builder};
/// Errors from fallible operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
+12 -7
View File
@@ -102,7 +102,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
}
// Open paths are cutting edges for Divide; the filled-only operations keep their
// implicit closure. Reuse Shape Builder's bounded planar map and its fallback.
if op == PathfinderOp::Divide && shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2) {
if op == PathfinderOp::Divide && crate::has_open_path(shapes) {
return crate::shape_builder(shapes, false)
.regions
.into_iter()
@@ -298,21 +298,22 @@ pub fn merge_regions(regions: &[&Region]) -> PathData {
/// outlines ([`merge_regions`]) could leave those edges in, as a hairline gap or a spur, where
/// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut, as
/// [`shape_builder`](crate::shape_builder) gives them) merge in their planar map the same way
/// (#893). Either falls back to [`merge_regions`] when it can't place a face.
/// (#893), and so do faces with a gap closed paths enclose, which no shape covers (#1691). Either
/// falls back to [`merge_regions`] when it can't place a face.
pub struct FaceMerger {
/// The arrangement (`None`: open paths, or the sweep failed) and the number of shapes.
arr: Option<Arrangement>,
shapes: usize,
/// With open paths, the shapes, and their planar map once a merge has needed it.
/// The shapes, and their planar map once a merge has needed it (open paths, or a gap).
lines: Option<(Vec<Shape>, std::cell::OnceCell<Option<crate::planar::Map>>)>,
}
impl FaceMerger {
pub fn new(shapes: &[Shape]) -> Self {
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
let has_open = crate::has_open_path(shapes);
// The Shape Builder's faces come from the planar map only up to its cap.
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let lines = (has_open && segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
let lines = (segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines }
}
@@ -321,8 +322,8 @@ impl FaceMerger {
self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces))
}
/// `faces` merged in the planar map of shapes with open paths: each face found again by a
/// point inside it, and the result checked to cover what they cover.
/// `faces` merged in the planar map of the shapes: each face found again by a point inside
/// it, and the result checked to cover what they cover.
fn planar(&self, faces: &[&Region]) -> Option<PathData> {
let (shapes, map) = self.lines.as_ref()?;
let map = map.get_or_init(|| crate::planar::map(shapes)).as_ref()?;
@@ -335,6 +336,10 @@ impl FaceMerger {
}
fn exact(&self, faces: &[&Region]) -> Option<PathData> {
// A gap no shape covers is no face of the arrangement: it merges in the planar map.
if faces.iter().any(|r| r.sources.is_empty()) {
return None;
}
let arr = self.arr.as_ref()?;
// Each face by its coverage and a point inside it.
let mut picks: Vec<(Vec<bool>, Point)> = Vec::with_capacity(faces.len());
+66 -14
View File
@@ -307,8 +307,9 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
build(shapes).unwrap_or_default()
}
/// Most segments the Shape Builder sweeps with open paths as edges: the planar map costs far more
/// than the filled areas' arrangement, so past this only the filled areas count.
/// Most segments the Shape Builder sweeps into its planar map (open paths as edges, the gaps closed
/// paths enclose, outline edges): the map costs far more than the filled areas' arrangement, so past
/// this only the filled areas count.
pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 4096;
/// The Shape Builder's arrangement: see [`shape_builder`].
@@ -322,20 +323,26 @@ pub struct BuilderArrangement {
pub edges: Vec<Shape>,
}
/// Does any of `shapes` have an open subpath (a cutting edge for the Shape Builder)?
pub fn has_open_path(shapes: &[Shape]) -> bool {
shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2)
}
/// Shape Builder regions of `shapes` (back → front), the pieces of their open paths and, with
/// `edges`, the pieces of their outlines (what erasing an edge deletes).
///
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions). Open
/// paths (the caller closes the ones whose fill should count) are cutting edges: a line across a
/// shape splits it, lines around an area enclose a region nothing fills (empty `sources`), and each
/// path with no closed subpath comes back cut wherever another path meets it, as pieces keyed by
/// its index in `shapes`. Outlines are cut the same way. With no open path, more than
/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions), and the
/// gaps they enclose that nothing fills are regions too (empty `sources`), such as the middle of
/// four circles that touch (#1691). Open paths (the caller closes the ones whose fill should count)
/// are cutting edges: a line across a shape splits it, lines around an area enclose a region
/// nothing fills, and each path with no closed subpath comes back cut wherever another path meets
/// it, as pieces keyed by its index in `shapes`. Outlines are cut the same way. With more than
/// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas
/// count and there are no pieces.
pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement {
let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2);
let has_open = has_open_path(shapes);
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let planar = ((has_open || edges) && segments <= SHAPE_BUILDER_MAX_SEGMENTS)
let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS)
.then(|| {
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
live_paint(&keyed)
@@ -346,11 +353,15 @@ pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement {
};
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
let (lines, outline): (Vec<Shape>, Vec<Shape>) = pieces.into_iter().partition(|e| is_line(e.key));
BuilderArrangement {
regions: if has_open { faces } else { crate::regions(shapes) },
lines: if has_open { lines } else { Vec::new() },
edges: if edges { outline } else { Vec::new() },
}
let regions = if has_open {
faces
} else {
// The filled areas from their arrangement, whose faces merge exactly, then the gaps.
let mut regions = crate::regions(shapes);
regions.extend(faces.into_iter().filter(|f| f.sources.is_empty()));
regions
};
BuilderArrangement { regions, lines: if has_open { lines } else { Vec::new() }, edges: if edges { outline } else { Vec::new() } }
}
/// Does `sp`, closed, enclose an area (rather than retrace itself)? The Shape Builder closes the
@@ -1182,6 +1193,43 @@ mod tests {
assert!((regions.iter().map(area).sum::<f64>() - 10000.0).abs() < 1e-6);
}
fn circle(cx: f64, cy: f64, r: f64, key: u64) -> Shape {
Shape::new(shapes::ellipse(Rect::new(cx - r, cy - r, cx + r, cy + r)), FillRule::NonZero, key)
}
#[test]
fn shape_builder_offers_the_gaps_closed_shapes_enclose() {
// Four circles in a 2×2 grid, each touching its neighbours (#1691): the four circles and
// the diamond between them, which nothing fills.
let s = [circle(50.0, 50.0, 50.0, 0), circle(150.0, 50.0, 50.0, 1), circle(50.0, 150.0, 50.0, 2), circle(150.0, 150.0, 50.0, 3)];
let regions = shape_builder(&s, false).regions;
assert_eq!(regions.len(), 5, "{regions:?}");
let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect();
assert_eq!(gaps.len(), 1);
assert!(gaps[0].contains(Point::new(100.0, 100.0)) && !gaps[0].contains(Point::new(50.0, 50.0)));
let diamond = 10000.0 - std::f64::consts::PI * 2500.0;
assert!((area(gaps[0]).abs() - diamond).abs() < 0.01 * diamond, "{}", area(gaps[0]));
assert_eq!(shape_builder(&s, true).regions, regions, "the outline edges don't change them");
// A ring of four bars that overlap at the corners: the eight pieces of the bars and the
// square they enclose.
let s =
[rect(0.0, 0.0, 300.0, 100.0, 0), rect(200.0, 0.0, 300.0, 300.0, 1), rect(0.0, 200.0, 300.0, 300.0, 2), rect(0.0, 0.0, 100.0, 300.0, 3)];
let regions = shape_builder(&s, false).regions;
assert_eq!(regions.len(), 9, "{regions:?}");
let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect();
assert_eq!(gaps.len(), 1);
assert!((area(gaps[0]).abs() - 10000.0).abs() < 1e-6 && gaps[0].contains(Point::new(150.0, 150.0)));
// The gap merges with a bar in the planar map: the bar's piece and the square, no seam.
let merger = crate::FaceMerger::new(&s);
let bar = regions.iter().find(|r| r.sources == [0] && r.contains(Point::new(150.0, 50.0))).unwrap();
let merged = merger.merge(&[bar, gaps[0]]);
assert_eq!(merged.subpaths.len(), 1, "{merged:?}");
assert!((crate::area(&merged, FillRule::NonZero) - 20000.0).abs() < 1e-6);
// Shapes apart or overlapping without a hole enclose no gap.
let s = [circle(50.0, 50.0, 50.0, 0), circle(250.0, 50.0, 50.0, 1), rect(80.0, 0.0, 220.0, 60.0, 2)];
assert!(shape_builder(&s, false).regions.iter().all(|r| !r.sources.is_empty()));
}
#[test]
fn shape_builder_stays_quick_up_to_its_cap() {
// A wavy ring of nearly the most segments the map takes, crossed by a few lines.
@@ -1194,6 +1242,10 @@ mod tests {
})
.collect();
let mut s = vec![Shape::new(PathData::single(SubPath::polyline(&ring, true)), FillRule::NonZero, 0)];
// Closed paths alone are swept for the gaps they enclose.
let started = std::time::Instant::now();
assert_eq!(shape_builder(&s, false).regions.len(), 1, "the ring encloses no gap");
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
for k in 0..4 {
let y = -60.0 + 40.0 * f64::from(k);
s.push(line((-150.0, y), (150.0, y + 7.0), 1));
+5 -2
View File
@@ -23,10 +23,11 @@ image = { workspace = true }
# CMYK JPEGs (the image crate writes RGB and grey ones only).
jpeg-encoder = { workspace = true }
craft-pdf = { workspace = true }
hayro-syntax = "0.7.2"
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
hayro-syntax = "0.8.0"
hayro-interpret = { version = "0.8.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] }
log = { workspace = true }
smallvec = "1"
weezl = "0.1"
serde = { workspace = true }
# PDF security (encrypt.rs): pure Rust, MIT OR Apache-2.0, builds for wasm.
aes = "0.8"
@@ -38,3 +39,5 @@ vectorcraft-testkit = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
serde_json = { workspace = true }
# `draw_text`, to write test files with live text as other writers do.
krilla = { workspace = true, features = ["simple-text"] }
+1 -1
View File
@@ -1330,7 +1330,7 @@ impl Exporter<'_> {
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false };
let art = vectorcraft_brush::stroke_pieces(b, bp, st);
let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st);
let mut pushes = 0;
if st.blend != BlendMode::Normal {
s.push_blend_mode(blend(st.blend));
+112 -66
View File
@@ -3,12 +3,12 @@
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use hayro_interpret::font::Glyph;
use hayro_interpret::font::{Glyph, GlyphRun as ShownGlyphs};
use hayro_interpret::hayro_cmap::BfString;
use hayro_interpret::pattern::{Pattern, TilingPattern};
use hayro_interpret::{
CacheKey, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData,
MaskType, PathDrawMode, SoftMask, StrokeProps, interpret_page,
CacheKey, ClipPath, Context, Device, DrawMode, DrawProps, Image, ImageData, ImageDrawProps, InterpreterCache, InterpreterSettings,
InterpreterWarning, LumaData, MaskType, SoftMask, StrokeProps, interpret_page,
};
use hayro_syntax::object::Name;
use kurbo::{Affine, BezPath, PathEl, Rect, Shape};
@@ -147,9 +147,15 @@ pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it,
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
// The interpreter draws forms, patterns, glyphs and masks inside one another by recursion, in
// large stack frames: it caps that (50 deep), and gets room for the cap whatever thread asks.
vectorcraft_doc::depth::with_deep_stack(|| read(bytes, opts))
}
/// [`import_with_report`] on the thread that asks.
pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
let original = crate::pages::open(bytes, opts.password.as_deref())?;
let picked = crate::pages::picked(opts, original.pages().len())?;
picked.iter().filter_map(|&n| original.pages().get(n)).try_for_each(crate::pages::content_too_deep)?;
let mut ocgs = Ocgs::read(&original);
let route = opts.layers && !ocgs.list.is_empty();
// Content that is off imports as hidden layers: an update of the file turns every group on
@@ -167,7 +173,6 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let all_on = turned_on.is_some();
let pdf = turned_on.unwrap_or(original);
let pages = pdf.pages();
picked.iter().filter_map(|&n| pages.get(n)).try_for_each(crate::pages::content_too_deep)?;
let sink: Arc<Mutex<Vec<String>>> = Arc::default();
let sink2 = sink.clone();
@@ -176,6 +181,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let msg = match w {
InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped",
InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped",
// Annotations aren't drawn (`render_annotations` below), and a kind of warning
// added later has no message yet.
_ => return,
};
if let Ok(mut v) = sink2.lock()
&& !v.iter().any(|x| x == msg)
@@ -1262,8 +1270,8 @@ impl<'p> Builder<'p> {
/// before it on the open line was stroked so too, is held with the line instead of ending it
/// (an editor writes type on a path glyph by glyph, each followed by the stroke added to the
/// type). `false`: it isn't one.
fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &PathDrawMode) -> bool {
let PathDrawMode::Stroke(props) = draw_mode else { return false };
fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &DrawMode) -> bool {
let DrawMode::Stroke(props) = draw_mode else { return false };
if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, hayro_interpret::Paint::Pattern(_)) {
return false;
}
@@ -1355,7 +1363,11 @@ impl<'p> Builder<'p> {
Some(art) => {
let [r, g, b, _] = m.background_color().to_rgba().components();
let backdrop = luminance(&Color::rgb(r, g, b));
let inverted = m.transfer_function().is_some_and(|tf| tf.apply(0.0) > tf.apply(1.0));
let inverted = m.transfer_function().is_some_and(|tf| {
let mut ends = [0, 255];
tf.apply_to(&mut ends);
ends[0] > ends[1]
});
let id = self.id();
mask_spec(art, m.mask_type() == MaskType::Alpha, backdrop, inverted, page, |all| Node::group(id, all))
}
@@ -1684,7 +1696,14 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec<u8> {
}
}
impl<'a> Device<'a> for Builder<'_> {
/// What the interpreter draws, as the device below gets it.
impl<'a> Builder<'_> {
/// The soft mask and blend mode of what is drawn next.
fn set_state(&mut self, mask: Option<SoftMask<'a>>, blend_mode: hayro_interpret::BlendMode) {
self.set_soft_mask(mask);
self.set_blend_mode(blend_mode);
}
fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
let spec = mask.map(|m| self.soft_mask(&m));
let same = match (&spec, &self.mask) {
@@ -1707,7 +1726,7 @@ impl<'a> Device<'a> for Builder<'_> {
}
}
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) {
fn path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &DrawMode) {
self.flush_glyphs();
if self.hold_glyph_stroke(path, transform, paint, draw_mode) {
self.pending = false;
@@ -1729,19 +1748,19 @@ impl<'a> Device<'a> for Builder<'_> {
hayro_interpret::Paint::Color(_) => None,
};
// Patch and triangle meshes are gradient meshes.
if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading)
if let (DrawMode::Fill(_), Some(sp)) = (draw_mode, shading)
&& let Some(meshes) = mesh_shading(sp, &mut self.colors)
{
self.draw_meshes(meshes, &bp, sp.opacity);
return;
}
let pd = PathData::from_bezpath(&bp);
let stroke = matches!(draw_mode, PathDrawMode::Stroke(_));
let stroke = matches!(draw_mode, DrawMode::Stroke(_));
let (paint, opacity) = self.paint(paint, stroke);
// A gradient that doesn't extend past its ends paints only between them.
let width = match draw_mode {
PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
PathDrawMode::Fill(_) => 0.0,
DrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
_ => 0.0,
};
let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width)));
let wrap = |b: &mut Self, n: Node| match &band {
@@ -1753,7 +1772,7 @@ impl<'a> Device<'a> for Builder<'_> {
None => n,
};
match draw_mode {
PathDrawMode::Fill(rule) => {
DrawMode::Fill(rule) => {
let mut f = FillLayer::new(paint);
(f.opacity, f.overprint) = (opacity, overprint);
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() });
@@ -1764,7 +1783,7 @@ impl<'a> Device<'a> for Builder<'_> {
let n = wrap(self, n);
self.push_node(n);
}
PathDrawMode::Stroke(props) => {
DrawMode::Stroke(props) => {
let mut st = stroke_layer(paint, opacity, props, mean_scale(transform));
st.overprint = overprint;
// Fill-then-stroke of the same path (the `B` operator, or a fill and then a stroke
@@ -1799,6 +1818,71 @@ impl<'a> Device<'a> for Builder<'_> {
let n = wrap(self, n);
self.push_node(n);
}
// (`draw_path` gives a fill and a stroke one after the other, and no path is invisible.)
DrawMode::FillAndStroke(..) | DrawMode::Invisible => {}
}
}
/// One glyph: filled, or stroked with `stroke`.
fn glyph(
&mut self,
glyph: &Glyph<'a>,
transform: Affine,
glyph_transform: Affine,
paint: &hayro_interpret::Paint<'a>,
stroke: Option<&StrokeProps>,
) {
self.pending = false;
match glyph {
Glyph::Outline(o) => {
let scale = mean_scale(transform);
let overprint = self.glyph_overprint(stroke.is_some());
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke, overprint) {
return;
}
self.flush_text();
let mut bp = o.outline();
bp.apply_affine(transform * glyph_transform);
let (paint, opacity) = self.paint(paint, stroke.is_some());
let same = self.glyphs.as_ref().is_some_and(|r| {
r.paint == paint
&& r.opacity == opacity
&& r.overprint == overprint
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.map(|s| s.line_width)
&& (r.scale - scale).abs() < 1e-9
});
if !same {
self.flush_glyphs();
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke: stroke.cloned(), scale, overprint });
}
if let Some(r) = &mut self.glyphs {
r.path.extend(bp.iter());
}
self.warn("text was converted to outlines");
}
Glyph::Type3(t3) => {
if self.nested >= MAX_NESTED {
return;
}
self.flush();
self.nested += 1;
t3.interpret(self, transform, glyph_transform, paint);
self.nested -= 1;
}
}
}
}
impl<'a> Device<'a> for Builder<'_> {
fn draw_path(&mut self, path: &BezPath, props: DrawProps<'a>, draw_mode: &DrawMode) {
self.set_state(props.soft_mask, props.blend_mode);
match draw_mode {
DrawMode::Invisible => {}
DrawMode::FillAndStroke(rule, stroke) => {
self.path(path, props.transform, &props.paint, &DrawMode::Fill(*rule));
self.path(path, props.transform, &props.paint, &DrawMode::Stroke(stroke.clone()));
}
one => self.path(path, props.transform, &props.paint, one),
}
}
@@ -1834,60 +1918,22 @@ impl<'a> Device<'a> for Builder<'_> {
self.blend = BlendMode::Normal;
}
fn draw_glyph(
&mut self,
glyph: &Glyph<'a>,
transform: Affine,
glyph_transform: Affine,
paint: &hayro_interpret::Paint<'a>,
draw_mode: &GlyphDrawMode,
) {
self.pending = false;
fn draw_glyph_run(&mut self, glyph_run: &ShownGlyphs<'_, 'a>, props: DrawProps<'a>, draw_mode: &DrawMode) {
// (A run is filled or stroked: one filled and stroked is given twice.)
let stroke = match draw_mode {
GlyphDrawMode::Invisible => return,
GlyphDrawMode::Fill => None,
GlyphDrawMode::Stroke(p) => Some(p.clone()),
DrawMode::Invisible => return,
DrawMode::Fill(_) => None,
DrawMode::Stroke(p) | DrawMode::FillAndStroke(_, p) => Some(p),
};
match glyph {
Glyph::Outline(o) => {
let scale = mean_scale(transform);
let overprint = self.glyph_overprint(stroke.is_some());
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) {
return;
}
self.flush_text();
let mut bp = o.outline();
bp.apply_affine(transform * glyph_transform);
let (paint, opacity) = self.paint(paint, stroke.is_some());
let same = self.glyphs.as_ref().is_some_and(|r| {
r.paint == paint
&& r.opacity == opacity
&& r.overprint == overprint
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width)
&& (r.scale - scale).abs() < 1e-9
});
if !same {
self.flush_glyphs();
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint });
}
if let Some(r) = &mut self.glyphs {
r.path.extend(bp.iter());
}
self.warn("text was converted to outlines");
}
Glyph::Type3(t3) => {
if self.nested >= MAX_NESTED {
return;
}
self.flush();
self.nested += 1;
t3.interpret(self, transform, glyph_transform, paint);
self.nested -= 1;
}
for g in glyph_run.glyphs() {
self.set_state(props.soft_mask.clone(), props.blend_mode);
self.glyph(g, props.transform, g.transform(), &props.paint, stroke);
}
}
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
fn draw_image(&mut self, image: Image<'a, '_>, props: ImageDrawProps<'a>) {
self.set_state(props.soft_mask, props.blend_mode);
let transform = props.transform;
self.flush_glyphs();
self.flush_text();
self.pending = false;
@@ -1975,7 +2021,7 @@ impl<'a> Device<'a> for Builder<'_> {
}
}
fn pop_clip_path(&mut self) {
fn pop_clip(&mut self) {
self.flush();
self.pending = false;
self.pop_frame();
+1 -1
View File
@@ -379,7 +379,7 @@ impl<'p> Colors<'p> {
}
/// An object written in place (references are visited as objects of their own).
fn direct(m: MaybeRef<Object<'_>>) -> Option<Object<'_>> {
pub(crate) fn direct(m: MaybeRef<Object<'_>>) -> Option<Object<'_>> {
match m {
MaybeRef::NotRef(o) => Some(o),
MaybeRef::Ref(_) => None,
+2 -1
View File
@@ -485,7 +485,8 @@ impl Walker<'_, '_> {
{
self.out.groups.push((g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)));
}
let inner = Resources::from_parent(d.get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
// (Its own resources, or without any those of what draws it: as the interpreter reads them.)
let inner = d.get::<Dict<'_>>(b"Resources").map(Resources::new).unwrap_or_else(|| res.clone());
self.save();
self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1);
self.restore();
+2 -2
View File
@@ -34,7 +34,7 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>) ->
let space = colors.shading_space(cs, at(0.0)?.len());
// RGB to decide which samples are stops; the stops keep the shading's model.
let sample = |t: f32| -> Option<Color> {
let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components();
let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0).components();
Some(Color::rgb(round3(r), round3(g), round3(b)))
};
// Sample densely, then drop samples that linear interpolation reproduces.
@@ -147,7 +147,7 @@ fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern
};
let space = colors.shading_space(&sp.shading.color_space, comps.len());
colors.native(space, &comps).map(|n| n.color).unwrap_or_else(|| {
let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0, false).components();
let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0).components();
Color::rgb(round3(r), round3(g), round3(b))
})
}
+1
View File
@@ -64,6 +64,7 @@ mod post;
mod presets;
mod print;
mod settings;
mod streams;
mod syntax;
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
+121 -58
View File
@@ -1,12 +1,16 @@
//! The pages of a PDF to import: opening with a password, the pages picked, the box each page
//! is cropped to ([`CropTo`]) and [`info`].
use hayro_syntax::object::{Dict, Stream};
use std::collections::HashMap;
use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream};
use hayro_syntax::page::Page;
use hayro_syntax::xref::XRef;
use hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
use kurbo::{Affine, Rect};
use vectorcraft_doc::setup::MAX_BLEED;
use crate::import_color::direct;
use crate::{Choice, CropTo, ImportOptions, PdfError};
/// What `info` tells about one page.
@@ -33,75 +37,134 @@ pub fn is_postscript(bytes: &[u8]) -> bool {
bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6])
}
/// The deepest a file's arrays and dictionaries may nest. The PDF reader (hayro) reads them by
/// recursion, without a cap of its own: a file nested deeply enough overflows the stack, which is
/// an abort, not an error. Far beyond what a PDF writer makes.
const MAX_OBJECT_NESTING: usize = 256;
/// The most functions and colour spaces that may be built on one another (a function stitching
/// functions together, an indexed space on a space with an alternate). The interpreter (hayro)
/// builds them by recursion, without a cap of its own: one built on itself, or thousands in a
/// chain, overflows the stack, which is an abort, not an error. Files have two or three.
const MAX_LINKS: usize = 16;
fn too_deep() -> PdfError {
PdfError::Parse(format!("its arrays and dictionaries are nested more than {MAX_OBJECT_NESTING} deep"))
/// The deepest the procedures of a calculator function may nest: read by recursion, too.
const MAX_PROCEDURES: usize = 64;
/// What the function or colour space `o` is built on: the functions a stitching function joins;
/// the base, alternate and tint of a colour space; the alternate of a colour profile. Nothing for
/// any other object.
fn links<'a>(o: &Object<'a>, xref: &'a XRef) -> Vec<MaybeRef<Object<'a>>> {
const SPACES: [&[u8]; 6] = [b"Indexed", b"I", b"Separation", b"DeviceN", b"Pattern", b"ICCBased"];
if let Object::Array(a) = o {
let mut items = a.raw_iter();
let kind = match items.next() {
Some(MaybeRef::NotRef(Object::Name(n))) => Some(n),
Some(MaybeRef::Ref(r)) => xref.get::<Name<'a>>(r.into()),
_ => None,
};
return if kind.is_some_and(|n| SPACES.contains(&n.as_ref())) { items.collect() } else { vec![] };
}
let Some((d, _)) = dict_or_stream(o) else { return vec![] };
let functions = d.get::<Array<'a>>(b"Functions").map(|a| a.raw_iter().collect::<Vec<_>>()).unwrap_or_default();
functions.into_iter().chain(d.get_raw::<Object<'a>>(b"Alternate")).collect()
}
/// Do the arrays and dictionaries in the text of `pdf` nest deeper than [`MAX_OBJECT_NESTING`]?
/// Read as tokens (strings and comments hold none), an object at a time. The data of a stream
/// with a filter is skipped: it isn't PDF text until decoded, which this doesn't do, so nesting
/// inside compressed streams is not seen here.
fn nests_too_deep(pdf: &[u8]) -> bool {
let after =
|from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at + what.len());
let word = |at: usize, w: &[u8]| pdf.get(at..).is_some_and(|rest| rest.starts_with(w));
let (mut depth, mut filtered) = (0usize, false);
let mut i = 0;
while let Some(&c) = pdf.get(i) {
i += 1;
/// How many of its kind the function or colour space `o` is built on, one on the next (0 for any
/// other object); `None` past `room`, or when it is built on itself. `known`: the objects looked
/// at so far (`None` while one is being looked at, too). One `None` refuses the file, so that an
/// object is `None` only for the room there was where it was first met doesn't matter.
fn built_on<'a>(o: MaybeRef<Object<'a>>, xref: &'a XRef, known: &mut HashMap<ObjectIdentifier, Option<usize>>, room: usize) -> Option<usize> {
let (id, o) = match o {
MaybeRef::Ref(r) => {
let id = ObjectIdentifier::from(r);
if let Some(found) = known.get(&id) {
return *found;
}
let Some(o) = xref.get::<Object<'a>>(id) else { return Some(0) };
known.insert(id, None);
(Some(id), o)
}
MaybeRef::NotRef(o) => (None, o),
};
let links = links(&o, xref);
let most = match room.checked_sub(1) {
_ if links.is_empty() => Some(0),
Some(room) => links.into_iter().try_fold(0, |most, on| built_on(on, xref, known, room).map(|n| most.max(n + 1))),
None => None,
};
// (What it is built on may be known already, and deeper than there is room for here.)
let most = most.filter(|n| *n <= room);
if let Some(id) = id {
known.insert(id, most);
}
most
}
/// Is `o` a calculator function whose procedures nest deeper than [`MAX_PROCEDURES`]?
fn procedures_too_deep(o: &Object<'_>) -> bool {
let Object::Stream(s) = o else { return false };
if s.dict().get::<u8>(b"FunctionType") != Some(4) {
return false;
}
let Ok(program) = s.decoded() else { return false };
let mut depth = 0usize;
program.iter().any(|c| {
match c {
b'%' => i += pdf.get(i..).and_then(|rest| rest.iter().position(|c| matches!(c, b'\n' | b'\r'))).unwrap_or(pdf.len()),
b'(' => {
// A string: parentheses nest in it, a backslash takes the next byte.
let mut open = 1usize;
while open > 0 {
match pdf.get(i) {
None => return false,
Some(b'\\') => i += 1,
Some(b'(') => open += 1,
Some(b')') => open -= 1,
Some(_) => {}
}
i += 1;
}
}
b'[' => depth += 1,
b']' => depth = depth.saturating_sub(1),
b'<' if pdf.get(i) == Some(&b'<') => (depth, i) = (depth + 1, i + 1),
b'>' if pdf.get(i) == Some(&b'>') => (depth, i) = (depth.saturating_sub(1), i + 1),
// A string in hexadecimal.
b'<' => i = after(i, b">").unwrap_or(pdf.len()),
b'/' if word(i - 1, b"/Filter") => filtered = true,
b'e' if word(i - 1, b"endobj") => (depth, filtered) = (0, false),
b'e' if word(i - 1, b"endstream") => i += 8,
b's' if word(i - 1, b"stream") => {
depth = 0;
if std::mem::take(&mut filtered) {
i = after(i, b"endstream").unwrap_or(pdf.len());
}
}
b'{' => depth += 1,
b'}' => depth = depth.saturating_sub(1),
_ => {}
}
if depth > MAX_OBJECT_NESTING {
depth > MAX_PROCEDURES
})
}
/// The error of a file with a stream the reader would fail on ([`crate::streams`]).
const STREAM_NOTE: &str = "a stream in it would decode to more than 256 MB, or its decode parameters can't be applied";
/// The error of a file [`nests_without_end`].
const NESTED_NOTE: &str = "its functions or colour spaces are built on one another without end";
/// Why `pdf` is refused, if it is: a stream the reader would fail on (its own checks come first:
/// the calculator functions are decoded below), or [`nests_without_end`].
fn hostile(pdf: &Pdf) -> Option<&'static str> {
if pdf.objects().into_iter().any(|o| matches!(&o, Object::Stream(s) if crate::streams::unsafe_stream(s))) {
return Some(STREAM_NOTE);
}
nests_without_end(pdf).then_some(NESTED_NOTE)
}
/// Does `pdf` hold functions or colour spaces built on one another past [`MAX_LINKS`] (or on
/// themselves), or a calculator function nested past [`MAX_PROCEDURES`]? Every object is looked
/// at, wherever it is used from: the interpreter builds them from resources, shadings, images,
/// patterns, masks and graphics states alike.
fn nests_without_end(pdf: &Pdf) -> bool {
let xref = pdf.xref();
let mut known = HashMap::new();
// The objects of the file, then what each holds directly (64 deep at most: the reader's cap).
let mut pending: Vec<Object<'_>> = pdf.objects().into_iter().collect();
while let Some(o) = pending.pop() {
if procedures_too_deep(&o) || built_on(MaybeRef::NotRef(o.clone()), xref, &mut known, MAX_LINKS).is_none() {
return true;
}
match &o {
Object::Array(a) => pending.extend(a.raw_iter().filter_map(direct)),
_ => pending.extend(dict_or_stream(&o).into_iter().flat_map(|(d, _)| d.entries()).filter_map(|(_, v)| direct(v))),
}
}
false
}
/// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions
/// password opens a file too.
///
/// What a hostile file nests or loops is the reader's to refuse (hayro 0.8 reads arrays and
/// dictionaries 64 deep and no deeper, wherever they are: the file's text, an object stream, the
/// content of a page, form, pattern or glyph; and follows a page tree or a chain of object streams
/// once). What it reads beyond that is left out, as an object it can't read. What the interpreter
/// builds by recursion with no cap, and streams it would decode without a ceiling or with a
/// predictor it can't apply, are refused here ([`hostile`], [`crate::streams`]).
pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result<Pdf, PdfError> {
if is_postscript(bytes) {
return Err(PdfError::PostScript);
}
if nests_too_deep(bytes) {
return Err(too_deep());
if crate::streams::unsafe_file_streams(bytes) {
return Err(PdfError::Parse(STREAM_NOTE.into()));
}
let password = password.unwrap_or_default();
let read = |pw: &str| Pdf::new_with_password(bytes.to_vec(), pw);
@@ -119,12 +182,12 @@ pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result<Pdf, PdfError
LoadPdfError::Decryption(DecryptionError::PasswordProtected) => PdfError::WrongPassword,
e => PdfError::Parse(format!("{e:?}")),
})
}
/// The error of a page whose content, decoded, nests arrays and dictionaries too deep (see
/// [`MAX_OBJECT_NESTING`]): its operands are read the same way as the file's objects.
pub(crate) fn content_too_deep(page: &Page<'_>) -> Result<(), PdfError> {
if page.page_stream().is_some_and(nests_too_deep) { Err(too_deep()) } else { Ok(()) }
// Twice: looking at every object makes the reader repair a damaged table of objects (once
// only), and the objects are other ones after that.
.and_then(|pdf| match (0..2).find_map(|_| hostile(&pdf)) {
Some(why) => Err(PdfError::Parse(why.into())),
None => Ok(pdf),
})
}
/// The pages and their boxes. An encrypted PDF needs its `password`
+367
View File
@@ -0,0 +1,367 @@
//! Streams looked at before the PDF reader (hayro) decodes them. It inflates Flate and LZW data
//! with no ceiling, so a few megabytes can ask for gigabytes (#1700), and it applies a predictor
//! before checking its parameters: `Columns 0` divides by zero, a huge `Columns` allocates a row
//! that large whatever the stream holds (#1701). A file with such a stream is refused instead.
use std::borrow::Cow;
use std::io::Read;
use hayro_syntax::content::TypedIter;
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, Stream};
/// The most a stream may decode to: 256 MB, the pixels of the largest image the app reads (64
/// million, 4 bytes each).
pub(crate) const MAX_DECODED: usize = 256 << 20;
/// The longest row a predictor may have, in bytes.
const MAX_ROW: usize = 1 << 24;
/// A stream that decodes to more than [`MAX_DECODED`].
struct TooBig;
/// The filters of the stream with dictionary `dict`, by name (`Filter` or `F`: a name or an array).
fn filters<'a>(dict: &Dict<'a>) -> Vec<Name<'a>> {
[&b"Filter"[..], b"F"]
.into_iter()
.find_map(|key| dict.get::<Name<'a>>(key).map(|n| vec![n]).or_else(|| dict.get::<Array<'a>>(key).map(|a| a.iter::<Name<'a>>().collect())))
.unwrap_or_default()
}
/// Every decode parameter dictionary of the stream with dictionary `dict`, as the reader may take
/// them (`DecodeParms` or `DP`: a dictionary, or an array with one for each filter).
fn params<'a>(dict: &Dict<'a>) -> Vec<Dict<'a>> {
let mut all = vec![];
for key in [&b"DecodeParms"[..], b"DP"] {
all.extend(dict.get::<Dict<'a>>(key));
all.extend(dict.get::<Array<'a>>(key).into_iter().flat_map(|a| a.iter::<Object<'a>>().filter_map(|o| o.into_dict()).collect::<Vec<_>>()));
}
all
}
/// Would the reader's predictor fail on `params`? It reads `Colors` and `BitsPerComponent` as
/// bytes and `Columns` as a size, multiplies the first two as bytes, divides by the length of a row
/// and allocates one before it checks anything.
fn bad_predictor(params: &Dict<'_>) -> bool {
if params.get::<u8>(b"Predictor").unwrap_or(1) == 1 {
return false;
}
let colors = usize::from(params.get::<u8>(b"Colors").unwrap_or(1));
let bits = usize::from(params.get::<u8>(b"BitsPerComponent").unwrap_or(8));
let columns = params.get::<usize>(b"Columns").unwrap_or(1);
let pixel = colors * bits;
let row = columns.checked_mul(pixel).map(|b| b.div_ceil(8));
!(matches!(bits, 1 | 2 | 4 | 8 | 16) && colors > 0 && pixel <= 255 && columns > 0 && row.is_some_and(|r| r <= MAX_ROW))
}
/// `data` inflated, up to one byte past [`MAX_DECODED`] (zlib, or raw deflate as the reader also
/// takes); `None` when it is damaged before that.
fn inflate(data: &[u8]) -> Option<Vec<u8>> {
let read = |mut r: Box<dyn Read + '_>| {
let mut out = vec![];
// (Damaged data: what came out before the damage still counts.)
let done = r.by_ref().take(MAX_DECODED as u64 + 1).read_to_end(&mut out).is_ok();
(done || out.len() > MAX_DECODED).then_some(out)
};
read(Box::new(flate2::read::ZlibDecoder::new(data))).or_else(|| read(Box::new(flate2::read::DeflateDecoder::new(data))))
}
/// `data` decoded from LZW, up to one byte past [`MAX_DECODED`]. `early`: the code length grows
/// one code early (`EarlyChange`, 1 by default).
fn lzw(data: &[u8], early: bool) -> Option<Vec<u8>> {
use weezl::{BitOrder, LzwStatus, decode::Decoder};
let mut decoder = if early { Decoder::with_tiff_size_switch(BitOrder::Msb, 8) } else { Decoder::new(BitOrder::Msb, 8) };
let (mut out, mut chunk, mut input) = (vec![], vec![0; 1 << 16], data);
while out.len() <= MAX_DECODED {
let r = decoder.decode_bytes(input, &mut chunk);
input = input.get(r.consumed_in..).unwrap_or_default();
out.extend_from_slice(chunk.get(..r.consumed_out).unwrap_or_default());
match r.status {
Ok(LzwStatus::Ok) => {}
Ok(LzwStatus::Done | LzwStatus::NoProgress) => break,
Err(_) => return (out.len() > MAX_DECODED).then_some(out),
}
}
Some(out)
}
/// `data` decoded from run-length encoding, up to one byte past [`MAX_DECODED`].
fn run_length(data: &[u8]) -> Vec<u8> {
let (mut out, mut at) = (vec![], 0);
while let Some(&n) = data.get(at)
&& n != 128
&& out.len() <= MAX_DECODED
{
if n < 128 {
let take = usize::from(n) + 1;
out.extend_from_slice(data.get(at + 1..).unwrap_or_default().get(..take).unwrap_or_default());
at += 1 + take;
} else {
out.extend(std::iter::repeat_n(data.get(at + 1).copied().unwrap_or(0), 257 - usize::from(n)));
at += 2;
}
}
out
}
/// `data` decoded from hexadecimal digits (white space skipped, up to `>`).
fn hex(data: &[u8]) -> Vec<u8> {
let digits: Vec<u8> = data.iter().take_while(|c| **c != b'>').filter_map(|c| char::from(*c).to_digit(16)).map(|d| d as u8).collect();
digits.chunks(2).map(|p| (p.first().copied().unwrap_or(0) << 4) | p.get(1).copied().unwrap_or(0)).collect()
}
/// `data` decoded from base-85 (white space skipped, `z` for four zeros, up to `~>`).
fn base85(data: &[u8]) -> Vec<u8> {
let (mut out, mut group) = (vec![], vec![]);
let flush = |group: &mut Vec<u32>, out: &mut Vec<u8>| {
let n = group.len();
if n > 1 {
group.resize(5, 84);
let v = group.iter().fold(0u32, |v, d| v.wrapping_mul(85).wrapping_add(*d));
out.extend_from_slice(v.to_be_bytes().get(..n - 1).unwrap_or_default());
}
group.clear();
};
for &c in data.iter().take_while(|c| **c != b'~') {
match c {
b'z' if group.is_empty() => out.extend([0; 4]),
b'!'..=b'u' => {
group.push(u32::from(c - b'!'));
if group.len() == 5 {
flush(&mut group, &mut out);
}
}
_ => {}
}
}
flush(&mut group, &mut out);
out
}
/// The most data of `len` bytes can grow to through `filters`: 1,032 times through Flate (its
/// largest ratio), 3,641 through LZW (4,096 bytes from a 9-bit code), 64 through run-length.
fn most_after(len: usize, filters: &[Name<'_>]) -> u128 {
filters.iter().fold(len as u128, |n, f| {
n.saturating_mul(match f.as_ref() {
b"FlateDecode" | b"Fl" => 1032,
b"LZWDecode" | b"LZW" => 3641,
b"RunLengthDecode" | b"RL" => 64,
_ => 1,
})
})
}
/// `data` (of the stream with dictionary `dict`) through its filters, as far as this reads them:
/// Flate, LZW, run-length and the ASCII encodings. Not decoded when it can't decode past
/// [`MAX_DECODED`] and `keep` doesn't ask for it, or when an image codec or damaged data comes
/// first (an image's own decoder knows its size).
fn decode(data: &[u8], dict: &Dict<'_>, keep: bool) -> Result<Option<Vec<u8>>, TooBig> {
let filters = filters(dict);
if !keep && most_after(data.len(), &filters) <= MAX_DECODED as u128 {
return Ok(None);
}
let params = params(dict);
let mut data = Cow::Borrowed(data);
for (i, f) in filters.iter().enumerate() {
let early = params.get(i).and_then(|p| p.get::<u8>(b"EarlyChange")).is_none_or(|e| e != 0);
let out = match f.as_ref() {
b"FlateDecode" | b"Fl" => inflate(&data),
b"LZWDecode" | b"LZW" => lzw(&data, early),
b"RunLengthDecode" | b"RL" => Some(run_length(&data)),
b"ASCIIHexDecode" | b"AHx" => Some(hex(&data)),
b"ASCII85Decode" | b"A85" => Some(base85(&data)),
_ => None,
};
match out {
Some(out) if out.len() > MAX_DECODED => return Err(TooBig),
Some(out) => data = Cow::Owned(out),
None => return Ok(None),
}
}
Ok(Some(data.into_owned()))
}
/// May the stream with dictionary `dict` be content (of a page, form, pattern or glyph), which can
/// hold inline images? Not an image, font, profile, function, metadata or object stream.
fn maybe_content(dict: &Dict<'_>) -> bool {
let named = |key: &[u8], ok: &[&[u8]]| dict.get::<Name<'_>>(key).is_none_or(|n| ok.contains(&n.as_ref()));
named(b"Subtype", &[b"Form"])
&& named(b"Type", &[b"XObject", b"Pattern"])
&& ![&b"Length1"[..], b"Length2", b"Length3", b"FunctionType", b"N"].iter().any(|k| dict.contains_key(k))
}
/// Would the reader fail on `stream` (or on an inline image in it, when it is content): a
/// predictor it can't apply, or more than [`MAX_DECODED`] decoded?
pub(crate) fn unsafe_stream(stream: &Stream<'_>) -> bool {
unsafe_data(stream, true)
}
/// [`unsafe_stream`], looking for inline images in it when `inside` (not in an inline image's own
/// data: it would be read again for each inline image a hostile one nests).
fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool {
let dict = stream.dict();
if params(dict).iter().any(bad_predictor) {
return true;
}
let content = inside && maybe_content(dict);
match decode(&stream.raw_data(), dict, content) {
Err(TooBig) => true,
Ok(Some(data)) if content && data.windows(2).any(|w| w == b"ID") => {
let mut ops = TypedIter::new(&data);
while let Some(op) = ops.next() {
if let TypedInstruction::InlineImage(image) = op
&& unsafe_data(image.0, false)
{
return true;
}
}
false
}
Ok(_) => false,
}
}
/// Would the reader fail on a stream it decodes while it opens `pdf`, before anything else can
/// look at it: an object stream or a cross-reference stream (read here from the file's text)?
/// Their decode parameters must be in their dictionary, as the reader can't be asked for others
/// yet. Encrypted object streams are looked at once the file is open (their data is ciphered
/// here).
pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool {
let find = |from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at);
let mut from = 0;
while let Some(at) = find(from, b"stream") {
from = at + 6;
if at >= 3 && pdf.get(at - 3..at) == Some(b"end") {
continue;
}
// The dictionary before it: back from its `>>` to the `<<` that opens it.
let Some(close) = pdf.get(..at).and_then(|head| head.iter().rposition(|c| !c.is_ascii_whitespace())).map(|i| i + 1) else { continue };
if close < 2 || pdf.get(close - 2..close) != Some(b">>") {
continue;
}
let (mut depth, mut open, mut i) = (0usize, None, close);
while i >= 2 && close - i < 1 << 16 {
match pdf.get(i - 2..i) {
Some(b">>") => (depth, i) = (depth + 1, i - 2),
Some(b"<<") => {
depth -= 1;
i -= 2;
if depth == 0 {
open = Some(i);
break;
}
}
_ => i -= 1,
}
}
let Some(dict) = open.and_then(|o| pdf.get(o..close)).and_then(Dict::from_bytes) else { continue };
if !dict.get::<Name<'_>>(b"Type").is_some_and(|t| matches!(t.as_ref(), b"ObjStm" | b"XRef")) {
continue;
}
let indirect = [&b"DecodeParms"[..], b"DP"].iter().any(|k| matches!(dict.get_raw::<Object<'_>>(k), Some(MaybeRef::Ref(_))));
if indirect || params(&dict).iter().any(bad_predictor) {
return true;
}
let start = from
+ usize::from(pdf.get(from) == Some(&b'\r'))
+ usize::from(pdf.get(from + usize::from(pdf.get(from) == Some(&b'\r'))) == Some(&b'\n'));
let end = dict
.get::<usize>(b"Length")
.and_then(|n| start.checked_add(n))
.filter(|e| *e <= pdf.len())
.or_else(|| find(start, b"endstream"))
.unwrap_or(pdf.len());
if matches!(decode(pdf.get(start..end).unwrap_or_default(), &dict, false), Err(TooBig)) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn dict(text: &str) -> Dict<'_> {
Dict::from_bytes(text.as_bytes()).unwrap()
}
#[test]
fn predictors_the_reader_can_apply_pass() {
for ok in [
"<< >>",
"<< /Predictor 1 /Columns 0 >>",
"<< /Predictor 12 /Columns 5 >>",
"<< /Predictor 2 /Colors 3 /BitsPerComponent 16 /Columns 1000 >>",
] {
assert!(!bad_predictor(&dict(ok)), "{ok}");
}
for bad in [
"<< /Predictor 2 /Columns 0 >>",
"<< /Predictor 12 /Colors 0 >>",
"<< /Predictor 2 /BitsPerComponent 0 >>",
"<< /Predictor 2 /BitsPerComponent 3 >>",
"<< /Predictor 12 /Colors 32 /BitsPerComponent 16 >>",
"<< /Predictor 15 /Columns 99999999999 >>",
"<< /Predictor 10 /Columns 18446744073709551615 /Colors 4 >>",
] {
assert!(bad_predictor(&dict(bad)), "{bad}");
}
}
#[test]
fn the_decoders_read_what_the_specification_writes() {
assert_eq!(hex(b"48 65 6c6C6\n>junk"), b"Hell`");
assert_eq!(base85(b"87cURD]i,\"Ebo80~>"), b"Hello World!");
assert_eq!(base85(b"z!!~>"), [0, 0, 0, 0, 0]);
assert_eq!(run_length(&[2, b'a', b'b', b'c', 254, b'x', 128, b'q']), b"abcxxx");
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
std::io::Write::write_all(&mut e, &[7; 1000]).unwrap();
assert_eq!(inflate(&e.finish().unwrap()).unwrap(), [7; 1000]);
// "-----A---B" in LZW, as the specification's example writes it (7.4.4.2).
let lzw_example = [0x80, 0x0B, 0x60, 0x50, 0x22, 0x0C, 0x0C, 0x85, 0x01];
assert_eq!(lzw(&lzw_example, true).unwrap(), b"-----A---B");
}
/// Zeros past the cap, compressed.
fn zeros_past_the_cap() -> Vec<u8> {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
for _ in 0..(MAX_DECODED >> 20) + 1 {
std::io::Write::write_all(&mut e, &[0; 1 << 20]).unwrap();
}
e.finish().unwrap()
}
#[test]
fn a_stream_that_decodes_past_the_cap_is_found_without_holding_it() {
// Inflated in pieces: never more than one byte past the cap is held.
let bomb = zeros_past_the_cap();
assert!(bomb.len() < MAX_DECODED / 500);
assert_eq!(inflate(&bomb).map(|d| d.len()), Some(MAX_DECODED + 1));
let d = dict("<< /Filter /FlateDecode >>");
assert!(matches!(decode(&bomb, &d, false), Err(TooBig)));
// Small enough that it can't get there: not decoded at all.
assert!(matches!(decode(&[1, 2, 3], &d, false), Ok(None)));
}
#[test]
fn object_and_cross_reference_streams_are_looked_at_before_the_file_is_read() {
let stream = |dict: &str, data: &[u8]| {
[format!("1 0 obj\n<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream\nendobj\n"].concat()
};
for bad in [
stream("/Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /DecodeParms << /Predictor 2 /Columns 0 >>", b"x"),
stream("/Type /XRef /Filter /FlateDecode /DecodeParms 9 0 R", b"x"),
stream("/Type /ObjStm /N 1 /First 4 /Filter /FlateDecode", &zeros_past_the_cap()),
] {
assert!(unsafe_file_streams(&bad));
}
for fine in [
stream("/Type /XRef /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 5 >>", b"x"),
// Another kind of stream is looked at once the file is open, its references followed.
stream("/Filter /FlateDecode /DecodeParms << /Predictor 2 /Columns 0 >>", b"x"),
] {
assert!(!unsafe_file_streams(&fine));
}
}
}
+43
View File
@@ -753,3 +753,46 @@ fn empty_and_populated_file_info_preserve_key_presence() {
}
assert!(s.contains("Synthetic title") && s.contains("Test author") && s.contains("test keyword"));
}
/// A function or colour space built on itself is refused before the interpreter builds it (by
/// recursion, with no cap: a stack overflow), and those of real files are read.
#[test]
fn functions_and_colour_spaces_built_on_themselves_are_refused() {
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
let first = first_extra(1);
let (me, next) = (format!("{first} 0 R"), format!("{} 0 R", first + 1));
let shaded = PdfPage {
resources: format!("/Shading << /S << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function {me} >> >>"),
..PdfPage::new(100.0, 100.0, "/S sh")
};
let filled = PdfPage { resources: format!("/ColorSpace << /C {me} >>"), ..PdfPage::new(100.0, 100.0, "/C cs 0 sc 0 0 50 50 re f") };
let stitch = |on: &str| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{on}] /Bounds [] /Encode [0 1] >>");
let indexed = |on: &str| format!("[/Indexed {on} 1 <000000ffffff>]");
let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>";
// `n` functions, each stitching the next, then a plain one.
let chain = |n: usize| (1..=n).map(|i| stitch(&format!("{} 0 R", first + i))).chain([ramp.to_string()]).collect::<Vec<_>>();
let calculator =
|program: &str| format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>\nstream\n{program}\nendstream", program.len());
for (what, page, objects) in [
("a function stitching itself", &shaded, vec![stitch(&me)]),
("two functions stitching each other", &shaded, vec![stitch(&next), stitch(&me)]),
("twenty functions, each stitching the next", &shaded, chain(20)),
("procedures in procedures", &shaded, vec![calculator(&format!("{}{}", "{".repeat(100), "}".repeat(100)))]),
("a colour space on itself", &filled, vec![indexed(&me)]),
("two colour spaces on each other", &filled, vec![indexed(&next), indexed(&me)]),
] {
let objects: Vec<&str> = objects.iter().map(String::as_str).collect();
let e = import(&pdf_with(std::slice::from_ref(page), &objects, None)).err().map(|e| e.to_string());
assert!(e.as_deref().is_some_and(|e| e.contains("built on one another without end")), "{what}: {e:?}");
}
for (what, page, objects) in [
("a function stitching one", &shaded, vec![stitch(&next), ramp.to_string()]),
("three functions, each stitching the next", &shaded, chain(3)),
("a calculator function", &shaded, vec![calculator("{ dup 1 exch sub 0 }")]),
("an indexed colour space", &filled, vec![indexed("/DeviceRGB")]),
] {
let objects: Vec<&str> = objects.iter().map(String::as_str).collect();
let d = import(&pdf_with(std::slice::from_ref(page), &objects, None)).unwrap_or_else(|e| panic!("{what}: {e}"));
assert!(d.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty())), "{what}: its art is read");
}
}
+3 -2
View File
@@ -287,7 +287,7 @@ fn a_sublayer_that_paints_first_brings_its_parent_layer() {
#[test]
fn hostile_layer_orders_stay_bounded() {
// A group listed twice, under itself, and an order nested far deeper than layers go.
let deep = format!("{}{{2}}{}", "[".repeat(100), "]".repeat(100));
let deep = format!("{}{{2}}{}", "[".repeat(40), "]".repeat(40));
let order = format!("[{{0}} [{{0}} {{1}}] {{1}} [{{0}}] {deep}]");
let d = import(&three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], &format!("/Order {order}"))).unwrap();
assert_eq!(names(&d), ["A", "C"], "C, listed too deep, stays a top-level layer");
@@ -317,7 +317,8 @@ fn thousands_of_layers_hidden_ones_included_all_come_in() {
/// Import `bytes` with the scan's operator limit lowered to `n`.
fn import_with_max_ops(bytes: &[u8], n: usize) -> ImportReport {
let old = crate::import_scan::TEST_MAX_OPS.replace(n);
let r = import_with_report(bytes, &ImportOptions::default());
// (On this thread: the limit is this thread's.)
let r = crate::import::read(bytes, &ImportOptions::default());
crate::import_scan::TEST_MAX_OPS.set(old);
r.unwrap()
}
+1 -1
View File
@@ -107,7 +107,7 @@ impl Renderer {
let pieces = match self.brushes.entries.get(&key, stamp) {
Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => e.pieces.clone(),
_ => {
let pieces = Arc::new(vectorcraft_brush::stroke_pieces(&brush, bp, st));
let pieces = Arc::new(vectorcraft_brush::stroke_pieces(f.doc, &brush, bp, st));
let bytes = size_of::<Entry>() + lru::path_bytes(bp) + pieces.iter().map(lru::node_bytes).sum::<usize>();
self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone() }, bytes, stamp);
pieces
+66 -16
View File
@@ -366,6 +366,29 @@ fn linear_key(view: Affine) -> [u64; 4] {
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
}
/// The raster a shadow or glow blurred by `blur` device pixels is filtered in, for content
/// reaching `reach` (device pixels) in a view of `screen` pixels: (left, top, width, height,
/// scale). It covers what of the reach can show in the view, the blur's spread around it
/// included, at `scale` raster pixels a device pixel: 1, or less to stay within
/// [`PIXEL_BUDGET`]. At a high zoom the spread of a wide blur is tens of thousands of pixels a
/// side, whatever the size of the view (#1706). `None`: nothing to draw.
fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64, u16, u16, f64)> {
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
let spread = blur.max(0.0) * 1.5 + 2.0;
let screen = Rect::new(0.0, 0.0, f64::from(screen.0), f64::from(screen.1)).inflate(spread, spread);
let r = reach.inflate(spread, spread).intersect(screen);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (rw, rh) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
// (Also false for a reach or a blur that isn't a number.)
if !(rw >= 1.0 && rh >= 1.0 && rw.is_finite() && rh.is_finite()) {
return None;
}
let side = f64::from(u16::MAX);
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
let (w, h) = ((rw * k).floor().clamp(1.0, side), (rh * k).floor().clamp(1.0, side));
Some((x0, y0, w as u16, h as u16, k))
}
/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel.
fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>, (x, y): (f64, f64), k: f64) {
let (w, h) = (image.width() as f64, image.height() as f64);
@@ -509,7 +532,8 @@ impl Drawn {
/// objects in it ([`Reads::of`]): which parts of [`Inputs`] can change what its rasters show.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Reads {
/// Pattern paints: the pattern definitions.
/// Pattern paints: the pattern definitions. Their art can hold anything, so content painted
/// with a pattern reads everything else too.
patterns: bool,
/// Symbol instances and type with inline graphics: the symbols' art. Symbol art can hold
/// anything, so content that shows a symbol reads everything else too.
@@ -527,14 +551,25 @@ pub(crate) struct Reads {
impl Reads {
/// What the content of `n` reads.
pub(crate) fn of(n: &Node) -> Self {
Self::of_all(std::iter::once(n))
}
/// What the content of `nodes` reads together. A pattern's art can hold anything, as a
/// symbol's can, so content painted with a pattern reads everything else too (#1723).
pub(crate) fn of_all<'a>(nodes: impl Iterator<Item = &'a Node>) -> Self {
let mut reads = Self::default();
reads.add(n);
if reads.symbols {
nodes.for_each(|n| reads.add(n));
if reads.symbols || reads.patterns {
return Self { patterns: true, symbols: true, images: true, brushes: true, text: true };
}
reads
}
/// Does the content read anything outside its own nodes?
pub(crate) fn any(self) -> bool {
self != Self::default()
}
fn add(&mut self, n: &Node) {
let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. });
n.walk(&mut |c| {
@@ -1365,18 +1400,9 @@ impl Renderer {
if !f.mt {
return self.inside_layer(|r| draw(r, ctx, f, composite));
}
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread);
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = ((r.x1.ceil() - x0).min(u16::MAX as f64), (r.y1.ceil() - y0).min(u16::MAX as f64));
if w < 1.0 || h < 1.0 {
return;
}
let (w, h) = (w as u16, h as u16);
let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return };
let mut off = f.offscreen_context(w, h);
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
let shifted = Frame { mt: false, view: Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view, px: f.px / k, ..*f };
self.inside_layer(|r| draw(r, &mut off, &shifted, None));
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
@@ -1386,7 +1412,12 @@ impl Renderer {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
}
crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0));
if k < 1.0 {
draw_scaled(ctx, std::sync::Arc::new(pm), (x0, y0), k);
ctx.set_transform(Affine::IDENTITY);
} else {
crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0));
}
if layered {
ctx.pop_layer();
}
@@ -1482,9 +1513,28 @@ mod tests {
use vectorcraft_doc::{Appearance, Document, Effect, Node};
use vectorcraft_geom::{Affine, Rect, shapes};
use super::Drawn;
use super::{Drawn, PIXEL_BUDGET, filter_region};
use crate::{RenderOptions, Rendered, Renderer};
/// #1706: the raster a shadow or glow is filtered in stays within the pixel budget however
/// far the blur spreads at the zoom (it was up to 65535 pixels a side: 16 GB).
#[test]
fn a_wide_blur_at_a_high_zoom_is_filtered_within_the_pixel_budget() {
// A 100 pt square at 3200 % and at 6400 %, blurred by 1000 pt, in a 1600 × 1000 view.
for zoom in [32.0, 64.0] {
let reach = Rect::new(0.0, 0.0, 100.0 * zoom, 100.0 * zoom);
let (x0, y0, w, h, k) = filter_region(reach, (1600, 1000), 1000.0 * zoom).unwrap();
assert!(f64::from(w) * f64::from(h) <= PIXEL_BUDGET, "{w} × {h}");
assert!(k < 1.0 && x0 < 0.0 && y0 < 0.0);
// It still covers the view and the blur's spread around it, at that scale.
assert!(f64::from(w) / k >= 1600.0 + 1500.0 * zoom && f64::from(h) / k >= 1000.0 + 1500.0 * zoom);
}
// A small blur is filtered at full size, cropped to what can show.
assert_eq!(filter_region(Rect::new(10.0, 20.0, 110.0, 70.0), (1600, 1000), 4.0), Some((2.0, 12.0, 116, 66, 1.0)));
assert_eq!(filter_region(Rect::new(5000.0, 0.0, 5100.0, 100.0), (1600, 1000), 4.0), None, "off the view");
assert_eq!(filter_region(Rect::new(0.0, 0.0, 100.0, 100.0), (1600, 1000), f64::NAN).map(|r| r.4), Some(1.0));
}
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
+11 -7
View File
@@ -83,7 +83,8 @@ pub struct RenderOptions {
pub artboards: bool,
/// Objects not to draw (e.g. the object being edited by a live drag preview).
pub hidden: Vec<NodeId>,
/// Draw template layers dimmed (50%).
/// Draw the layers as their Layer Options show them on screen: Preview off in outline, Dim
/// Images fading their images (a template layer's too), a template in Preview in Outline view.
pub dim_templates: bool,
/// Soft proof / separations preview (see [`proof`]).
pub proof: Option<proof::ProofSetup>,
@@ -489,6 +490,9 @@ pub struct Renderer {
isolation: Option<(u64, usize, NodeId, Vec<NodeId>)>,
/// Drawing inside the isolated container or a dimmed object: nothing further dims.
isolation_settled: bool,
/// The art the last frame drew with Overprint Preview or Separations Preview: the next frame
/// draws it again for the objects that have not changed ([`proof::prepare`]).
proof_art: Option<proof::Prepared>,
}
/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing.
@@ -591,6 +595,7 @@ impl Renderer {
raster: vello_cpu::RasterizerSettings::default(),
isolation: None,
isolation_settled: false,
proof_art: None,
}
}
@@ -615,7 +620,7 @@ impl Renderer {
}
// The document's own symbols: proofing rewrites the colors and blend modes of their art.
self.check_symbols(doc);
let prepared = proof::prepare(doc, opts);
let prepared = proof::prepare(doc, opts, &mut self.proof_art);
let doc: &Document = &prepared;
let w = width.clamp(1, u16::MAX as u32) as u16;
let h = height.clamp(1, u16::MAX as u32) as u16;
@@ -1088,8 +1093,8 @@ impl Renderer {
return;
}
// Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images
// fades its images (exports and thumbnails, which leave templates out, ignore both).
// (A template is dimmed as a whole already.)
// fades its images, a template's too (exports and thumbnails, which leave templates out,
// ignore both).
if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind
&& f.opts.dim_templates
&& !f.opts.skip_templates
@@ -1104,7 +1109,7 @@ impl Renderer {
let opts = RenderOptions { outline: true, ..f.opts.clone() };
return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force);
}
if let Some(p) = dim_images.filter(|_| !template) {
if let Some(p) = *dim_images {
let outer = self.dim_images;
let dim = f32::from(p).clamp(0.0, 100.0) / 100.0;
self.dim_images = Some(outer.map_or(dim, |o| o.min(dim)));
@@ -1162,8 +1167,7 @@ impl Renderer {
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
}
let outline = f.opts.outline;
let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates;
let opacity = if template_dim { self.opacity_of(n) * 0.5 } else { self.opacity_of(n) };
let opacity = self.opacity_of(n);
// Whether this container's children knock each other out.
let knockout = !outline && n.knocks_out(self.knockout);
let enclosing = std::mem::replace(&mut self.knockout, knockout);
+64 -4
View File
@@ -6,15 +6,19 @@
//! with the paint transform mapping pixels → pattern space → document. Tiles are cached per
//! pattern name and raster scale (and ink plane, see [`crate::ink`]) within
//! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision".
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision", and,
//! when its art shows more than itself (a symbol, another pattern, an image, type, a brush), while
//! what the document holds of those is what it held then ([`Deps`]), and while the color settings
//! are the ones it was drawn through.
//!
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
use std::cell::RefCell;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use std::sync::{Arc, Weak};
use vectorcraft_color::cms::Cms;
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
use vectorcraft_doc::{Document, Node};
use vectorcraft_geom::{Affine, Rect, Shape};
@@ -32,8 +36,54 @@ const MAX_TILE_PX: f64 = 2048.0;
/// ([`raster_scale`]).
type TileKey = (String, u64);
/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself:
/// the art of every pattern and symbol and the pixels of every image in the document, the
/// document's other data (which holds its brushes), and the fonts. Editing a symbol leaves the art
/// of a pattern that shows it as it was, so the art alone kept the tile from before (#1723). Held
/// weakly: a weak reference keeps the allocation, so no other value gets one of these addresses
/// while the tile is kept.
struct Deps {
art: Vec<Weak<Node>>,
pixels: Vec<Weak<Vec<u8>>>,
/// shortcut: any change to the document's other data draws the tile again, not only a brush
/// edit; compare the brush library alone if that ever costs a frame.
unknown: Weak<vectorcraft_brush::serde_json::Map<String, vectorcraft_brush::serde_json::Value>>,
fonts: u64,
}
impl Deps {
fn art(doc: &Document) -> impl Iterator<Item = &Arc<Node>> {
doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art))
}
fn pixels(doc: &Document) -> impl Iterator<Item = &Arc<Vec<u8>>> {
doc.images.values().flat_map(|b| std::iter::once(&b.bytes).chain(&b.proxy))
}
fn of(doc: &Document) -> Self {
Self {
art: Self::art(doc).map(Arc::downgrade).collect(),
pixels: Self::pixels(doc).map(Arc::downgrade).collect(),
unknown: vectorcraft_doc::Shared::downgrade(&doc.unknown),
fonts: vectorcraft_text::FontDb::global().generation(),
}
}
/// Does `doc` hold what the tile was drawn from?
fn current(&self, doc: &Document) -> bool {
self.fonts == vectorcraft_text::FontDb::global().generation()
&& std::ptr::eq(self.unknown.as_ptr(), &*doc.unknown)
&& self.art.iter().map(Weak::as_ptr).eq(Self::art(doc).map(Arc::as_ptr))
&& self.pixels.iter().map(Weak::as_ptr).eq(Self::pixels(doc).map(Arc::as_ptr))
}
}
struct TileEntry {
art: Vec<Arc<Node>>,
/// `None` for art that shows only itself.
deps: Option<Deps>,
/// The color settings the tile was drawn through, held weakly as [`Deps`] holds its values.
cms: Weak<Cms>,
params: (Rect, TileType, Overlap),
sx: f64,
sy: f64,
@@ -159,11 +209,20 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
let scale = raster_scale(s, (pw, ph));
let params = (def.tile, def.tile_type, def.overlap);
let key = (name.to_string(), scale.to_bits());
let cms = vectorcraft_color::cms::active();
let hit = CACHE.with(|c| {
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize];
let t = tiles.tick();
tiles.get(&key, t).filter(|e| e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
tiles
.get(&key, t)
.filter(|e| {
e.params == params
&& same_art(&e.art, &def.art)
&& std::ptr::eq(e.cms.as_ptr(), Arc::as_ptr(&cms))
&& e.deps.as_ref().is_none_or(|d| d.current(doc))
})
.map(|e| (e.pixmap.clone(), e.sx, e.sy))
});
let (pm, sx, sy) = match hit {
Some(h) => h,
@@ -176,11 +235,12 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
};
let pm = Arc::new(pm);
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc));
CACHE.with(|c| {
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize];
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
tiles.insert(key, TileEntry { art: def.art.clone(), params, sx, sy, pixmap: pm.clone() }, bytes, t);
tiles.insert(key, TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone() }, bytes, t);
});
(pm, sx, sy)
}
+157 -21
View File
@@ -18,14 +18,15 @@
//! [`crate::RenderOptions`] with [`active_proof`] and [`overprint_preview_on`].
use std::borrow::Cow;
use std::sync::RwLock;
use std::collections::HashMap;
use std::sync::{Arc, RwLock, Weak};
use vectorcraft_color::Color;
use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES};
pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget};
use vectorcraft_doc::Document;
use vectorcraft_color::{Color, Swatch, SwatchGroup};
pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color};
use vectorcraft_doc::overprint::multiply_overprints;
use vectorcraft_doc::{Document, Node, NodeId, Shared};
use crate::RenderOptions;
@@ -63,29 +64,164 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String],
Color::Rgb { r, g, b }
}
/// The document as it should be drawn for these options (overprints, separations).
pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Document> {
let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s)));
let overprint = overprints(opts) && (doc.layers.iter().any(|l| l.has_overprint()) || doc.symbols.iter().any(|s| s.art.has_overprint()));
if !overprint && seps.is_none() {
/// The art a renderer drew with Overprint Preview or Separations Preview, kept for its next frame
/// ([`prepare`]): the document's layers and symbol art as they were, each beside the art drawn for
/// it, and what that art was made with. The kept nodes keep their addresses: no other node gets
/// one of them while they are kept.
pub(crate) struct Prepared {
inputs: Inputs,
layers: (Vec<Arc<Node>>, Vec<Arc<Node>>),
art: (Vec<Arc<Node>>, Vec<Arc<Node>>),
}
/// What [`prepare`] reads besides the art.
#[derive(PartialEq)]
struct Inputs {
discard_white: bool,
plates: Option<Plates>,
}
/// What the colors of Separations Preview's plates are made from ([`plate_color`]).
struct Plates {
/// The proof setup, with the visible plates.
proof: ProofSetup,
/// The document's swatches, swatch groups and Spot Colors option.
swatches: Shared<Vec<Swatch>>,
groups: Shared<Vec<SwatchGroup>>,
spot_use_lab: bool,
/// The color settings, held weakly: no other settings get their address while they are kept.
cms: Weak<Cms>,
/// [`cms::profiles_generation`].
profiles: u64,
}
impl PartialEq for Plates {
fn eq(&self, o: &Self) -> bool {
(&self.proof, &self.swatches, &self.groups, self.spot_use_lab, self.profiles)
== (&o.proof, &o.swatches, &o.groups, o.spot_use_lab, o.profiles)
&& Weak::ptr_eq(&self.cms, &o.cms)
}
}
/// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply
/// ([`multiply_overprints`]), and with Separations Preview every color shows as the visible plates
/// print it ([`plate_color`]).
struct How<'a> {
doc: &'a Document,
discard_white: bool,
/// Separations Preview: the color settings, the proof setup and the visible plates.
plates: Option<(&'a Cms, &'a ProofSetup, &'a [String])>,
}
impl How<'_> {
/// Whether [`Self::apply`] can change `n`: with Separations Preview any node, else a node with
/// an overprinting fill or stroke.
fn changes(&self, n: &Node) -> bool {
self.plates.is_some() || n.appearance.items.iter().any(|i| i.overprint())
}
/// Change the fills, strokes and other colors of node `a`, which holds no members.
fn apply(&self, a: &mut Arc<Node>) {
multiply_overprints(a, self.discard_white);
if let Some((c, proof, visible)) = self.plates {
map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw));
}
}
}
/// The document as it should be drawn for these options (overprints, separations). `kept` holds
/// the art of an earlier frame ([`Prepared`]): when that frame's inputs were the same, each layer,
/// symbol art and member that is still the node its kept art was made from is drawn from that art,
/// with its `Arc`. `kept` then holds this frame's art, or nothing when neither preview is on.
pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions, kept: &mut Option<Prepared>) -> Cow<'a, Document> {
if !overprints(opts) {
*kept = None;
return Cow::Borrowed(doc);
}
let mut d = doc.clone();
if overprint {
let discard_white = doc.setup.discard_white_overprint;
for l in &mut d.layers {
multiply_overprints(l, discard_white);
}
for s in &mut d.symbols {
multiply_overprints(&mut s.art, discard_white);
let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s)));
let c = cms::active();
let inputs = Inputs {
discard_white: doc.setup.discard_white_overprint,
plates: seps.map(|(proof, _)| Plates {
proof: proof.clone(),
swatches: doc.swatches.clone(),
groups: doc.swatch_groups.clone(),
spot_use_lab: doc.spot_use_lab,
cms: Arc::downgrade(&c),
profiles: cms::profiles_generation(),
}),
};
let earlier = kept.take().filter(|k| k.inputs == inputs);
let how = How { doc, discard_white: inputs.discard_white, plates: seps.map(|(proof, visible)| (&*c, proof, visible.as_slice())) };
let none = (vec![], vec![]);
let (layers, art) = earlier.as_ref().map_or((&none, &none), |k| (&k.layers, &k.art));
let made_layers = prepare_list(&doc.layers, &layers.0, &layers.1, &how);
let symbol_art: Vec<Arc<Node>> = doc.symbols.iter().map(|s| s.art.clone()).collect();
let made_art = prepare_list(&symbol_art, &art.0, &art.1, &how);
let same = |made: &[Arc<Node>], from: &[Arc<Node>]| made.iter().zip(from).all(|(a, b)| Arc::ptr_eq(a, b));
let out = if same(&made_layers, &doc.layers) && same(&made_art, &symbol_art) {
Cow::Borrowed(doc)
} else {
let mut d = doc.clone();
d.layers.clone_from(&made_layers);
for (s, a) in d.symbols.iter_mut().zip(&made_art) {
s.art = a.clone();
}
Cow::Owned(d)
};
*kept = Some(Prepared { inputs, layers: (doc.layers.clone(), made_layers), art: (symbol_art, made_art) });
out
}
/// `nodes` as drawn with `how` ([`prepare_node`]), each against its earlier version in `was`: the
/// node at its place when that node has its id, else the node with its id. `made` holds the art
/// drawn for `was`, place by place.
fn prepare_list(nodes: &[Arc<Node>], was: &[Arc<Node>], made: &[Arc<Node>], how: &How) -> Vec<Arc<Node>> {
let mut places: Option<HashMap<NodeId, usize>> = None;
nodes
.iter()
.enumerate()
.map(|(i, n)| {
let at = match was.get(i) {
Some(w) if w.id == n.id => Some(i),
_ => places.get_or_insert_with(|| was.iter().enumerate().map(|(j, w)| (w.id, j)).collect()).get(&n.id).copied(),
};
prepare_node(n, at.and_then(|j| Some((was.get(j)?, made.get(j)?))), how)
})
.collect()
}
/// `n` as drawn with `how`: the art of `earlier` (a node and the art drawn for it) when that art
/// was made from `n` itself; else a copy of `n` with its own fills and strokes changed
/// ([`How::apply`]) and its members prepared against those of `earlier` ([`prepare_list`]), or `n`
/// itself when neither changes.
fn prepare_node(n: &Arc<Node>, earlier: Option<(&Arc<Node>, &Arc<Node>)>, how: &How) -> Arc<Node> {
if let Some((was, made)) = earlier
&& Arc::ptr_eq(was, n)
{
return made.clone();
}
if let Some((proof, visible)) = seps {
let c = cms::active();
let src = doc.clone();
map_document_colors(&mut d, &mut |col, sw| plate_color(&src, &c, proof, visible, col, sw));
let members = n.children().map(|ch| {
let (was, made) = earlier.and_then(|(w, m)| Some((w.children()?.as_slice(), m.children()?.as_slice()))).unwrap_or_default();
prepare_list(ch, was, made, how)
});
let same_members = match (&members, n.children()) {
(Some(m), Some(ch)) => m.iter().zip(ch).all(|(a, b)| Arc::ptr_eq(a, b)),
_ => true,
};
if same_members && !how.changes(n) {
return n.clone();
}
Cow::Owned(d)
let mut own = Node::clone(n);
if let Some(ch) = own.children_mut() {
ch.clear();
}
let mut a = Arc::new(own);
how.apply(&mut a);
if let (Some(m), Some(ch)) = (members, Arc::make_mut(&mut a).children_mut()) {
*ch = m;
}
a
}
/// Soft-proof the rendered (premultiplied RGBA8) pixels in place.
+26 -2
View File
@@ -67,13 +67,37 @@ fn dim_images_fades_only_the_images() {
assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed");
}
/// #1683: a template layer dims its images by its Dim Images percentage and nothing else: with
/// the option off they show at full strength, and the layer's paths are never faded.
#[test]
fn a_template_layer_dims_its_images_by_dim_images() {
let template = |dim: Option<u8>| {
let d = doc(|k| {
if let NodeKind::Layer { template, dim_images, .. } = k {
(*template, *dim_images) = (true, dim);
}
});
let img = render(&d, false);
(img.pixel(65, 25), img.pixel(25, 25))
};
let red = [255, 0, 0, 255];
assert_eq!(template(None), (red, red), "Dim Images off: as it is");
let (image, path) = template(Some(50));
assert!(image[0] == 255 && (120..=135).contains(&image[1]) && image[1] == image[2], "half way to white: {image:?}");
assert_eq!(path, red, "vector art stays");
let (image, _) = template(Some(20));
assert!((198..=210).contains(&image[1]) && image[1] == image[2], "a fifth of its strength: {image:?}");
assert_eq!(template(Some(100)), (red, red));
}
/// In Outline view a template layer still draws in Preview, dimmed, so its picture can be traced;
/// the layer above it draws in outline (#1486).
#[test]
fn a_template_layer_stays_in_preview_in_outline_view() {
let mut d = doc(|k| {
if let NodeKind::Layer { template, preview, .. } = k {
*template = true;
if let NodeKind::Layer { template, preview, dim_images, .. } = k {
// As Layer Options makes a template: its images dimmed to 50 %.
(*template, *dim_images) = (true, Some(50));
// Preview off doesn't put a template in outline either.
*preview = false;
}
+200
View File
@@ -940,6 +940,63 @@ fn rasters_draw_pattern_edits() {
});
}
/// #1723: a pattern's tile is drawn again when what its art shows changes while the art itself
/// doesn't: a symbol an instance in it shows, another pattern it is painted with, or a brush it is
/// stroked with. So is a raster effect on an object painted with such a pattern.
#[test]
fn pattern_tiles_draw_edits_to_what_their_art_shows() {
let _fonts = fonts_steady();
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
use vectorcraft_geom::shapes::rectangle;
let tile = |name: &str, art: Node| {
let mut def = PatternDef::new(name, vec![Arc::new(art)]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
def
};
// Pattern `Stamps` shows symbol `S`; pattern `Outer` is a square painted with pattern `Checks`.
let doc = |pattern: &str, effects: Vec<vectorcraft_doc::Effect>| {
let mut d = Document::new(200.0, 100.0);
let art = symbol_art(&mut d);
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((10.0, 10.0)) });
d.patterns.push(tile("Stamps", shown));
d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0)));
let square = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), pattern_paint("Checks"), vec![]);
d.patterns.push(tile("Outer", square));
let painted = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), pattern_paint(pattern), effects);
with_art(d, vec![painted])
};
let new_symbol = |d: &mut Document| {
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let bar = Node::path(d.alloc_id(), rectangle(Rect::new(-8.0, -2.0, 8.0, 2.0)), red);
d.symbols[0].art = Arc::new(bar);
};
let new_checks = |d: &mut Document| {
if let Some(def) = d.pattern_mut("Checks") {
def.art = checks(Color::rgb(0.0, 0.0, 1.0)).art;
}
};
let blur = || vec![effect("blur.gaussian", serde_json::json!({"radius": 2.0}))];
edit_draws_as_new(doc("Stamps", vec![]), new_symbol);
edit_draws_as_new(doc("Stamps", blur()), new_symbol);
edit_draws_as_new(doc("Outer", vec![]), new_checks);
edit_draws_as_new(doc("Outer", blur()), new_checks);
// Pattern `Brushed` is a line stroked with a calligraphic brush; the brush gets wider.
let mut d = Document::new(200.0, 100.0);
let mut stroke = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let brushed = Node::path(d.alloc_id(), vectorcraft_geom::shapes::line((2.0, 10.0).into(), (18.0, 10.0).into()), stroke);
d.patterns.push(tile("Brushed", brushed));
let painted = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), pattern_paint("Brushed"), vec![]);
edit_draws_as_new(with_art(d, vec![painted]), |d| {
let mut lib = vectorcraft_brush::library(d);
if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut lib[0].kind {
c.size = 12.0;
}
vectorcraft_brush::store(d, &lib);
});
}
/// Raster effects are made again when a symbol shown inside another symbol changes (a group's drop
/// shadow around an instance of the other symbol), when a brush definition changes (a brushed
/// stroke's drop shadow), and when an image's pixels change under the same key, as when a linked
@@ -1093,6 +1150,149 @@ fn rasters_follow_the_view_options() {
draws_as_new(&mut d, &isolated, &on_white, unchanged);
}
/// A document for the proof previews: a cyan square, a magenta circle over it whose black stroke
/// overprints, a white square over the cyan one that overprints, a blurred rectangle in spot color
/// `Spot`, and an instance of symbol `S`, a group holding a yellow square.
fn proof_doc() -> Document {
use vectorcraft_geom::shapes::{ellipse, rectangle};
let mut d = Document::new(200.0, 100.0);
let orange = Color::cmyk(0.0, 0.6, 1.0, 0.0);
d.swatches.push(vectorcraft_color::Swatch { name: "Spot".into(), paint: Paint::solid(orange), global: true, spot: true });
let cyan = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), vec![]);
let black = Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0));
let ring = vectorcraft_doc::Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), black, 4.0);
let mut circle = Node::path(d.alloc_id(), ellipse(Rect::new(40.0, 20.0, 100.0, 80.0)), ring);
circle.appearance.stroke_mut().unwrap().overprint = true;
let mut white = filled(&mut d, rectangle(Rect::new(20.0, 30.0, 40.0, 50.0)), Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 0.0)), vec![]);
white.appearance.fill_mut().unwrap().overprint = true;
let spot = Paint::Solid { color: orange, swatch: Some("Spot".into()), tint: 1.0 };
let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0}));
let spot = filled(&mut d, rectangle(Rect::new(110.0, 10.0, 150.0, 50.0)), spot, vec![blur]);
let yellow = filled(&mut d, rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0)), vec![]);
let art = Node::group(d.alloc_id(), vec![Arc::new(yellow)]);
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((175.0, 75.0)) });
with_art(d, vec![cyan, circle, white, spot, instance])
}
/// Overprint Preview, and Separations Preview showing `plates`, which turns Overprint Preview on
/// too (as the canvas draws them).
fn proof_views(plates: &[&str]) -> (RenderOptions, RenderOptions) {
let overprint = RenderOptions { overprint_preview: true, ..Default::default() };
let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() };
(overprint.clone(), RenderOptions { proof: Some(proof), ..overprint })
}
/// [`proof_views`] with every plate of [`proof_doc`].
fn every_plate() -> (RenderOptions, RenderOptions) {
proof_views(&["Cyan", "Magenta", "Yellow", "Black", "Spot"])
}
/// With Overprint Preview or Separations Preview on, a second frame of an unchanged document draws
/// the art the first prepared for the preview: it makes no geometry entries. After an object
/// moves, a frame makes them for the object and its layer only. A frame drawn with neither preview
/// lets go of the art, and each frame drawn with a preview draws what a new renderer draws.
#[test]
fn proof_previews_keep_their_art_between_frames() {
let _fonts = fonts_steady();
let d = proof_doc();
let (overprint, separations) = every_plate();
let frame = |r: &mut Renderer, d: &Document, opts: &RenderOptions| r.render(d, 200, 100, Affine::IDENTITY, opts).pixels;
let geometry = |r: &Renderer| r.geom.keys().copied().collect::<std::collections::HashSet<_>>();
for opts in [&overprint, &separations] {
let mut r = Renderer::new();
let first = frame(&mut r, &d, opts);
let kept = geometry(&r);
assert!(frame(&mut r, &d, opts) == first);
assert_eq!(geometry(&r), kept, "the second frame makes no geometry entries");
// The cyan square moves 5 pt right.
let mut moved = d.clone();
let square = moved.layers[0].children().unwrap()[0].id;
moved.node_mut(square).unwrap().transform(Affine::translate((5.0, 0.0)), false);
assert!(frame(&mut r, &moved, opts) == frame(&mut Renderer::new(), &moved, opts));
assert_eq!(geometry(&r).difference(&kept).count(), 2, "the square and its layer");
frame(&mut r, &moved, &RenderOptions::default());
assert!(r.proof_art.is_none(), "drawn without the previews, the renderer keeps no art for them");
}
}
/// With Overprint Preview or Separations Preview on, a renderer draws what a new renderer draws
/// after a change to the art or to what the previews read: an object recolored, Discard White
/// Overprint turned off, a symbol's art recolored (its instance shows the plates of the new art),
/// the spot color redefined, a spot color in a color group redefined, the Spot Colors option set
/// to CMYK values, a plate hidden, Simulate Paper Color, another proof profile, and one preview
/// after the other.
#[test]
fn proof_previews_draw_changes_as_a_new_renderer_does() {
let _fonts = fonts_steady();
let (overprint, separations) = every_plate();
let unchanged = |_: &mut Document| {};
let recolor_square = |d: &mut Document| {
let square = d.layers[0].children().unwrap()[0].id;
d.node_mut(square).unwrap().appearance.set_fill(Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0)));
};
let recolor_symbol = |d: &mut Document| {
let mut art = (*d.symbols[0].art).clone();
if let Some(square) = art.children_mut().and_then(|c| c.first_mut()) {
Arc::make_mut(square).appearance.set_fill(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)));
}
d.symbols[0].art = Arc::new(art);
};
for opts in [&overprint, &separations] {
draws_as_new(&mut proof_doc(), opts, opts, recolor_square);
draws_as_new(&mut proof_doc(), opts, opts, |d| d.setup.discard_white_overprint = false);
}
draws_as_new(&mut proof_doc(), &separations, &separations, recolor_symbol);
draws_as_new(&mut proof_doc(), &separations, &separations, |d| {
if let Some(s) = d.swatch_mut("Spot") {
s.paint = Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0));
}
});
// A square in spot color `Lab Spot`, defined in Lab in a color group, on its own plate.
let lab_doc = || {
let mut d = proof_doc();
let lab = Color::lab(50.0, 60.0, 40.0);
let swatch = vectorcraft_color::Swatch { name: "Lab Spot".into(), paint: Paint::solid(lab), global: true, spot: true };
d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Lab".into(), swatches: vec![swatch] });
let fill = Paint::Solid { color: lab, swatch: Some("Lab Spot".into()), tint: 1.0 };
let square = filled(&mut d, vectorcraft_geom::shapes::rectangle(Rect::new(115.0, 60.0, 155.0, 95.0)), fill, vec![]);
with_art(d, vec![square])
};
let lab_plate = proof_views(&["Cyan", "Magenta", "Yellow", "Black", "Lab Spot"]).1;
draws_as_new(&mut lab_doc(), &lab_plate, &lab_plate, |d| {
if let Some(s) = d.swatch_mut("Lab Spot") {
s.paint = Paint::solid(Color::lab(70.0, -40.0, 30.0));
}
});
draws_as_new(&mut lab_doc(), &lab_plate, &lab_plate, |d| d.spot_use_lab = false);
let hidden = proof_views(&["Magenta", "Yellow", "Black", "Spot"]).1;
draws_as_new(&mut proof_doc(), &separations, &hidden, unchanged);
let with = |f: fn(&mut proof::ProofSetup)| {
let mut opts = separations.clone();
if let Some(p) = &mut opts.proof {
f(p);
}
opts
};
draws_as_new(&mut proof_doc(), &separations, &with(|p| p.simulate_paper = true), unchanged);
let device = with(|p| p.target = proof::ProofTarget::Cmyk(vectorcraft_color::cms::DEVICE_CMYK.into()));
draws_as_new(&mut proof_doc(), &separations, &device, unchanged);
let cyan = proof_views(&["Cyan"]).1;
draws_as_new(&mut proof_doc(), &overprint, &cyan, unchanged);
draws_as_new(&mut proof_doc(), &cyan, &overprint, unchanged);
// On the Yellow plate the instance is solid while its art is yellow, and blank once it is cyan.
let yellow = proof_views(&["Yellow"]).1;
let mut d = proof_doc();
let mut r = Renderer::new();
let instance = |r: &mut Renderer, d: &Document| {
let pixels = r.render(d, 200, 100, Affine::IDENTITY, &yellow).pixels;
Rendered { width: 200, height: 100, pixels }.pixel(175, 75)
};
assert_eq!(instance(&mut r, &d), [0, 0, 0, 255]);
recolor_symbol(&mut d);
assert_eq!(instance(&mut r, &d), [255, 255, 255, 255]);
}
/// A thumbnail of an object under a filter that treats color channels apart (Color Halftone) is
/// made again when the document's color mode changes: CMYK documents screen ink channels, RGB
/// documents light ones.
+1 -1
View File
@@ -917,7 +917,7 @@ impl Writer<'_> {
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(b, bp, st)),
Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(doc, b, bp, st)),
None => StrokePlan::Written(stroke::for_writer(bp, st)),
}
}
+16 -2
View File
@@ -20,8 +20,22 @@ use super::css::{Styles, XNode, parse_decls};
/// that hold one object aren't kept, and what is still deeper is merged into the group around
/// it). usvg uses about 15 kB of stack for each level: the importer runs on a thread with 16 MB
/// ([`vectorcraft_doc::depth::with_deep_stack`]), except on the web, where there is the one
/// 1 MB stack.
pub const MAX_ELEMENT_DEPTH: usize = if cfg!(target_arch = "wasm32") { 48 } else { 256 };
/// stack: [`WEB_DEPTH`] there.
pub const MAX_ELEMENT_DEPTH: usize = if cfg!(target_arch = "wasm32") { WEB_DEPTH } else { 256 };
/// The stack of the web build: rustc links `wasm32-unknown-unknown` with
/// `-z stack-size=1048576` (`rustc --print link-args` shows it), and the web app gives the linker
/// no arguments of its own (`apps/vectorcraft-web`: `Trunk.toml`, `index.html`, no rustflags).
const WEB_STACK: usize = 1 << 20;
/// The stack usvg uses for each level of elements: about 70 levels overflowed the 1 MB main
/// thread of Windows. Measured natively, never in a browser.
const LEVEL_STACK: usize = 15 << 10;
/// The deepest elements may nest on the web: half its stack at usvg's cost a level, the other
/// half left for what is on the stack when a file is opened and for the cost being a native
/// measure. 1,048,576 / (2 × 15,360) = 34.
const WEB_DEPTH: usize = WEB_STACK / (2 * LEVEL_STACK);
/// Properties whose value an element's descendants use too: the paint server they name is
/// converted at each descendant, under everything between.
+55 -8
View File
@@ -1113,9 +1113,9 @@ impl Pen<'_> {
}
/// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then
/// every ½ inch). Right, centre and decimal stops align the text that follows (`rest`, up to the
/// next tab) on the stop.
fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> f64 {
/// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal
/// stops align the text that follows (`rest`, up to the next tab) on the stop.
fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) {
use vectorcraft_doc::TabAlign;
let rel = x - origin;
let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect();
@@ -1128,14 +1128,52 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S
};
let explicit = tabs.iter().find_map(|t| {
let at = target(t.position, t.align, t.align_on);
(at > rel + 1e-6).then_some(at)
(at > rel + 1e-6).then_some((at, t.leader.as_str()))
});
let at = explicit.unwrap_or_else(|| {
let (at, leader) = explicit.unwrap_or_else(|| {
let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0);
let base = rel.max(last);
((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL
(((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL, "")
});
(at - rel).max(0.0)
((at - rel).max(0.0), leader)
}
/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make
/// millions of outlines.
const MAX_LEADER_COPIES: f64 = 4096.0;
/// The outline of a tab leader: `leader`'s characters, in the font and size of tab `g`, repeated
/// across the tab's gap `from..to` on baseline `y`. Copies sit on a grid of the leader's width
/// from `origin` (where tab stops are measured from), so the leaders of lines above one another
/// line up; only whole copies are drawn. Characters the font lacks draw nothing.
fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, from: f64, to: f64, y: f64) -> BezPath {
let mut out = BezPath::new();
let glyphs: Vec<(u32, f64)> = leader
.chars()
.filter(|c| !c.is_control())
.map(|c| {
let gid = g.face.glyph_for(c);
(gid, g.face.advance(gid) * g.sx)
})
.collect();
let width: f64 = glyphs.iter().map(|(_, a)| a).sum();
if !(width > EPS && width.is_finite() && from.is_finite() && to.is_finite()) {
return out;
}
let first = ((from - origin) / width - EPS).ceil();
let copies = (((to - origin) / width + EPS).floor() - first).clamp(0.0, MAX_LEADER_COPIES) as usize;
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((0.0, -g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
for k in 0..copies {
let mut pen = origin + (first + k as f64) * width;
for &(gid, adv) in &glyphs {
if gid != 0 {
let m = Affine::translate((pen, y)) * local;
out.extend(db.outline(&g.face, gid).elements().iter().map(|el| m * *el));
}
pen += adv;
}
}
out
}
/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces
@@ -1734,8 +1772,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
if j + 1 == trimmed {
adv -= end_trim;
}
let mut leader = "";
if g.ch == '\t' {
adv = tab_advance(&para.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
(adv, leader) = tab_advance(&para.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
} else if j < body {
if g.is_space() {
adv += per_space;
@@ -1750,6 +1789,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
}
let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical);
cx.emit(g, Affine::translate((x, y)), Point::new(x, y), 0.0, adv, li);
if !leader.is_empty() {
// The leader belongs to the tab's glyph: the gap stays one character for the
// caret, selection and hit testing.
let dots = leader_outline(cx.db, g, leader, tab_origin, x, x + adv, y);
if let Some(tab) = cx.out.glyphs.last_mut() {
tab.outline.extend(dots);
}
}
x += adv;
if j < trimmed {
x_end = x;
+27
View File
@@ -466,6 +466,33 @@ fn tab_stops_position_text() {
assert!((x_of(&p, 4) - 72.0).abs() < 1e-6);
}
#[test]
fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() {
use vectorcraft_doc::{TabAlign, TabStop};
let mut t = point("A\t1\nAAAAA\t2", style(12.0));
t.para.tabs = vec![TabStop { position: 120.0, align: TabAlign::Left, leader: ".".into(), align_on: '.' }];
let lay = layout(db(), &t);
// The left edges of the dots of each line's tab.
let dots = |byte: usize| -> Vec<f64> {
let tab = lay.glyphs.iter().find(|g| g.byte == byte).unwrap();
let mut xs: Vec<f64> = vec![];
for el in tab.outline.elements() {
if let kurbo::PathEl::MoveTo(p) = el {
xs.push(p.x);
}
}
xs.sort_by(f64::total_cmp);
xs.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
xs
};
let (short, long) = (dots(1), dots(9));
assert!(short.len() > long.len() && long.len() > 10, "{short:?} {long:?}");
// The longer line's dots are the shorter line's last ones, at the same x.
let tail = &short[short.len() - long.len()..];
assert!(tail.iter().zip(&long).all(|(a, b)| (a - b).abs() < 1e-6), "{tail:?} {long:?}");
assert!(short.last().is_some_and(|&x| x < 120.0));
}
/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when
/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts.
fn japanese_fonts() -> bool {
+27 -11
View File
@@ -9,7 +9,7 @@
//! Visibility is a painter's algorithm, as for Revolve: faces of a closed solid that face away
//! from the viewer are left out, the rest are drawn back to front.
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect};
use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale};
@@ -290,7 +290,11 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves
/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take
/// few points and large ones more; more than `limit` points in all is refused.
fn rings(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
///
/// Under the nonzero `rule` an outline with filled art on both sides is no edge of the shape and
/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would
/// if nesting alone decided (#1724).
fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
let tolerance = (size / 2000.0).max(0.05);
let mut count = 0;
let mut out = vec![];
@@ -325,6 +329,18 @@ fn rings(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, St
out.push(ring);
}
}
if rule == FillRule::NonZero {
let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 };
let mut edge = Vec::with_capacity(out.len());
for (i, r) in out.iter().enumerate() {
let p = r.first().copied().unwrap_or_default();
// The winding number just outside the ring, and just inside it.
let outside: i32 = out.iter().enumerate().filter(|(j, o)| *j != i && contains(o, p)).map(|(_, o)| turn(o)).sum();
edge.push((outside != 0) != (outside + turn(r) != 0));
}
let mut edge = edge.into_iter();
out.retain(|_| edge.next().unwrap_or(true));
}
// A ring inside an odd number of others is a hole.
let holes: Vec<bool> = out
.iter()
@@ -634,14 +650,14 @@ fn steps(o: &Extrude) -> Vec<Step> {
/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels
/// and caps.
pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
check_bounds(bounds)?;
let o = options.sanitized();
// No depth: the flat shape, seen from either side.
if o.depth <= 0.0 {
return plane(path, bounds, view);
return plane(path, rule, bounds, view);
}
let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
let camera = Camera::new(view, bounds, o.depth);
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] };
let steps = steps(&o);
@@ -675,9 +691,9 @@ pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> R
/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the
/// shape's own, so that every part of one object turns about the same point).
pub fn plane(path: &PathData, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
check_bounds(bounds)?;
let rings = rings(path, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
let camera = Camera::new(view, bounds, 0.0);
let mut b = Builder { camera: &camera, closed: false, facets: vec![] };
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
@@ -997,18 +1013,18 @@ fn material(outline: &PathData, cell: Rect) -> Option<Vec<Vec<Point>>> {
&[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)],
true,
));
let rule = vectorcraft_geom::FillRule::NonZero;
let rule = FillRule::NonZero;
let part =
vectorcraft_pathops::try_boolean(&r, rule, outline, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION)
.ok()?;
rings(&part, cell.width(), MAX_PLANE_POINTS).ok()
rings(&part, rule, cell.width(), MAX_PLANE_POINTS).ok()
}
/// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is.
pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> Result<Vec<Facet>, String> {
pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result<Vec<Facet>, String> {
check_bounds(bounds)?;
let o = options.sanitized();
let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
if rings.is_empty() {
return Ok(vec![]);
}
+104 -60
View File
@@ -1,4 +1,4 @@
use vectorcraft_geom::{BezPath, PathData, Point, Rect};
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect};
use super::*;
@@ -41,7 +41,7 @@ fn close(a: f64, b: f64, tol: f64) -> bool {
#[test]
fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() {
let (p, b) = shape(&square(10.0, 20.0, 100.0));
let f = extrude(&p, b, Extrude::default(), front()).unwrap();
let f = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap();
assert_eq!(f.len(), 1, "the walls and back face away: only the front cap shows");
assert_eq!(f[0].contours.len(), 1);
assert_eq!(extent(&f), Rect::new(10.0, 20.0, 110.0, 120.0));
@@ -51,7 +51,7 @@ fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() {
fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let o = Extrude { depth: 40.0, ..Extrude::default() };
let f = extrude(&p, b, o, View { rotation_y: 30.0, ..front() }).unwrap();
let f = extrude(&p, FillRule::EvenOdd, b, o, View { rotation_y: 30.0, ..front() }).unwrap();
// Orthographic: the silhouette is the front's width times cos 30° plus the depth times sin 30°.
let w = extent(&f).width();
assert!(close(w, 100.0 * 30f64.to_radians().cos() + 40.0 * 30f64.to_radians().sin(), 1e-6), "{w}");
@@ -66,10 +66,10 @@ fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() {
fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() };
let flat = extrude(&p, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap();
assert_eq!(flat, plane(&p, b, v).unwrap());
let solid = extrude(&p, b, Extrude::default(), v).unwrap();
let hollow = extrude(&p, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap();
let flat = extrude(&p, FillRule::EvenOdd, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap();
assert_eq!(flat, plane(&p, FillRule::EvenOdd, b, v).unwrap());
let solid = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap();
let hollow = extrude(&p, FillRule::EvenOdd, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap();
// Solid: front, right and top faces. Hollow: all four walls (two seen from inside), no caps.
assert_eq!(solid.len(), 3);
assert_eq!(hollow.len(), 4);
@@ -81,10 +81,10 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let side = View { rotation_y: 90.0, ..front() };
let o = Extrude { depth: 60.0, cap: false, ..Extrude::default() };
let straight = extent(&extrude(&p, b, o, side).unwrap());
let straight = extent(&extrude(&p, FillRule::EvenOdd, b, o, side).unwrap());
assert!(close(straight.height(), 100.0, 1e-6) && close(straight.width(), 60.0, 1e-6));
// Seen from the side, a 50 % taper keeps the front's full height and halves the back's.
let tapered = extrude(&p, b, Extrude { taper: 0.5, ..o }, side).unwrap();
let tapered = extrude(&p, FillRule::EvenOdd, b, Extrude { taper: 0.5, ..o }, side).unwrap();
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x0, 1e-6)).collect();
let (lo, hi) = back.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y)));
let at_front: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x1, 1e-6)).collect();
@@ -93,11 +93,11 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
assert!(close(front_h, 100.0, 1e-6) && close(back_h, 50.0, 1e-6), "front {front_h}, back {back_h}");
// Seen from behind, a 45° twist shows the back cap turned into a diamond.
let back = View { rotation_y: 180.0, ..front() };
let twisted = extrude(&p, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap();
let twisted = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap();
assert_eq!(twisted.len(), 1, "only the back cap faces the viewer");
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
// Its walls are cut into slices that follow the turn.
let open = extrude(&p, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
let open = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
assert!(open.len() > 2 * 8, "{}", open.len());
}
@@ -105,13 +105,13 @@ fn taper_shrinks_the_back_and_twist_turns_it() {
fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() };
let inside = extrude(&p, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap();
let inside = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap();
// Straight on: the cap inset by the width, and the chamfer around it filling the outline.
let cap = inside.iter().find(|f| close(signed_area(&f.contours[0]).abs(), 80.0 * 80.0, 1e-6));
assert!(cap.is_some(), "an 80 × 80 cap");
assert_eq!(extent(&inside), Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(close(area(&inside), 100.0 * 100.0, 1e-6));
let outside = extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap();
let outside = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap();
assert_eq!(extent(&outside), Rect::new(-10.0, -10.0, 110.0, 110.0), "the walls stand 10 pt outside the art");
assert!(outside.iter().any(|f| close(signed_area(&f.contours[0]).abs(), 100.0 * 100.0, 1e-6)), "the cap is the art");
// The chamfer's faces lean towards the light differently from the cap.
@@ -123,12 +123,16 @@ fn bevel_shapes_repeats_and_both_sides_change_the_surface() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
let base = Bevel { width: 12.0, height: 12.0, ..Bevel::default() };
let classic = extrude(&p, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap();
let classic = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap();
for shape in BevelShape::ALL.into_iter().skip(1) {
assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(), "{shape:?}");
assert_ne!(
classic,
extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(),
"{shape:?}"
);
}
for bevel in [Bevel { repeat: 3, ..base }, Bevel { repeat: 3, space: 0.3, ..base }, Bevel { both_sides: true, ..base }] {
assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}");
assert_ne!(classic, extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}");
}
// The step shape keeps its corners: half the width at half the height.
assert_eq!(BevelShape::Step.at(0.5), (0.5, 0.5));
@@ -147,11 +151,11 @@ fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() {
// The hole drawn the same way round as the outline: even-odd nesting still makes it a hole.
b.extend(square(30.0, 30.0, 40.0));
let (p, r) = shape(&b);
let f = extrude(&p, r, Extrude::default(), front()).unwrap();
let f = extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap();
assert_eq!(f.len(), 1);
assert_eq!(f[0].contours.len(), 2);
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-6));
let turned = extrude(&p, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap();
let turned = extrude(&p, FillRule::EvenOdd, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap();
// Front cap, the outline's right wall and the hole's left wall (seen through the hole).
assert_eq!(turned.len(), 3);
}
@@ -159,15 +163,15 @@ fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() {
#[test]
fn a_plane_turns_about_the_given_centre() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let f = plane(&p, b, View { rotation_y: 60.0, ..front() }).unwrap();
let f = plane(&p, FillRule::EvenOdd, b, View { rotation_y: 60.0, ..front() }).unwrap();
assert_eq!(f.len(), 1);
let e = extent(&f);
assert!(close(e.width(), 50.0, 1e-6) && close(e.center().x, 50.0, 1e-9));
// Turned about the centre of a larger box, the square moves as well as narrowing.
let wide = plane(&p, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap();
let wide = plane(&p, FillRule::EvenOdd, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap();
assert!(close(extent(&wide).x0, 150.0 - 150.0 * 0.5, 1e-6));
// Its back shows when turned past 90°: a plane is two-sided.
assert_eq!(plane(&p, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1);
assert_eq!(plane(&p, FillRule::EvenOdd, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1);
}
#[test]
@@ -175,7 +179,7 @@ fn inflate_covers_the_shape_and_puffs_by_the_volume() {
let mut c = BezPath::new();
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
let (p, b) = shape(&c);
let f = inflate(&p, b, Inflate::default(), front()).unwrap();
let f = inflate(&p, FillRule::EvenOdd, b, Inflate::default(), front()).unwrap();
// Straight on, the cushion covers the circle (sampled as a polygon) and nothing else.
assert!(close(area(&f), std::f64::consts::PI * 50.0 * 50.0, 60.0), "{}", area(&f));
assert!(extent(&f).x0 >= 50.0 - 1e-6 && extent(&f).x1 <= 150.0 + 1e-6);
@@ -184,7 +188,7 @@ fn inflate_covers_the_shape_and_puffs_by_the_volume() {
assert!(hi - lo > 0.3, "{lo}..{hi}");
// From the side: 50 % volume rounds a disc into a half sphere on a flat back.
let side = View { rotation_y: 90.0, ..front() };
let thick = |o: Inflate| extent(&inflate(&p, b, o, side).unwrap()).width();
let thick = |o: Inflate| extent(&inflate(&p, FillRule::EvenOdd, b, o, side).unwrap()).width();
assert!(close(thick(Inflate::default()), 50.0, 2.0), "{}", thick(Inflate::default()));
assert!(close(thick(Inflate { both_sides: true, ..Inflate::default() }), 100.0, 4.0));
assert!(close(thick(Inflate { depth: 30.0, ..Inflate::default() }), 80.0, 2.0));
@@ -197,7 +201,7 @@ fn inflate_leaves_holes_open() {
let mut b = square(0.0, 0.0, 100.0);
b.extend(square(30.0, 30.0, 40.0));
let (p, r) = shape(&b);
let f = inflate(&p, r, Inflate::default(), front()).unwrap();
let f = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).unwrap();
assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-3), "{}", area(&f));
}
@@ -205,9 +209,9 @@ fn inflate_leaves_holes_open() {
fn malformed_and_degenerate_input_is_bounded() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
for r in [Rect::new(f64::NAN, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, 1e9, 1.0)] {
assert!(extrude(&p, r, Extrude::default(), front()).is_err());
assert!(inflate(&p, r, Inflate::default(), front()).is_err());
assert!(plane(&p, r, front()).is_err());
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_err());
assert!(inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).is_err());
assert!(plane(&p, FillRule::EvenOdd, r, front()).is_err());
}
let wild = Extrude {
depth: f64::INFINITY,
@@ -216,18 +220,24 @@ fn malformed_and_degenerate_input_is_bounded() {
cap: true,
bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }),
};
let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap();
let f = extrude(&p, FillRule::EvenOdd, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap();
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
assert!(
inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, front())
.is_ok()
inflate(
&p,
FillRule::EvenOdd,
b,
Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true },
front()
)
.is_ok()
);
assert!(extrude(&PathData::default(), b, Extrude::default(), front()).unwrap().is_empty());
assert!(extrude(&PathData::default(), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap().is_empty());
// A line has no area to extrude.
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((100.0, 0.0));
assert!(extrude(&PathData::from_bezpath(&line), b, Extrude::default(), front()).unwrap().is_empty());
assert!(extrude(&PathData::from_bezpath(&line), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap().is_empty());
// Too many anchors: refused, not truncated.
let mut many = BezPath::new();
many.move_to((0.0, 0.0));
@@ -235,15 +245,15 @@ fn malformed_and_degenerate_input_is_bounded() {
many.line_to((f64::from(i), f64::from(i % 7)));
}
many.close_path();
assert!(extrude(&PathData::from_bezpath(&many), b, Extrude::default(), front()).is_err());
assert!(extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).is_err());
}
#[test]
fn perspective_makes_the_near_face_larger() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let o = Extrude { depth: 100.0, ..Extrude::default() };
let flat = extent(&extrude(&p, b, o, front()).unwrap());
let deep = extent(&extrude(&p, b, o, View { perspective: 30.0, ..front() }).unwrap());
let flat = extent(&extrude(&p, FillRule::EvenOdd, b, o, front()).unwrap());
let deep = extent(&extrude(&p, FillRule::EvenOdd, b, o, View { perspective: 30.0, ..front() }).unwrap());
assert!(deep.width() > flat.width() + 1.0);
assert!(close(deep.center().x, flat.center().x, 1e-6));
}
@@ -257,7 +267,7 @@ fn a_wider_perspective_brings_the_eye_nearer() {
let mut last = 100.0;
for angle in [20.0, 60.0, 110.0, 160.0] {
// The front face alone shows: its width is the cube's on the page.
let width = extent(&extrude(&p, b, o, View { perspective: angle, ..front() }).unwrap()).width();
let width = extent(&extrude(&p, FillRule::EvenOdd, b, o, View { perspective: angle, ..front() }).unwrap()).width();
assert!(width > last, "{angle}°: {width} after {last}");
last = width;
}
@@ -274,10 +284,10 @@ fn the_inflate_membrane_matches_known_solutions() {
let mut c = BezPath::new();
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01));
let (p, b) = shape(&c);
let disc = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), b.width() / 48.0, 49, 49);
let disc = Membrane::solve(&rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), b.width() / 48.0, 49, 49);
assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak);
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let sq = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), 100.0 / 48.0, 49, 49);
let sq = Membrane::solve(&rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), 100.0 / 48.0, 49, 49);
assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak);
// Smooth across the middle: no crease where a distance-to-the-edge height would have one
// (the diagonals). The second difference along a diagonal stays small and even.
@@ -303,10 +313,10 @@ fn inflate_cells_across_a_hole_edge_are_cut_to_the_shape() {
b.close_path();
let (p, r) = shape(&b);
let v = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..front() };
let f = inflate(&p, r, Inflate::default(), v).unwrap();
let f = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), v).unwrap();
assert!(f.iter().all(|f| f.contours.len() == 1), "every facet is one piece of the surface");
// Straight on, the facets still cover exactly the shape.
let flat = inflate(&p, r, Inflate::default(), front()).unwrap();
let flat = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).unwrap();
assert!(close(area(&flat), 220.0 * 220.0 - 100.0 * 90.0, 1e-3), "{}", area(&flat));
}
@@ -332,19 +342,19 @@ fn bumps(count: usize) -> (PathData, Rect) {
fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
// 300 curves were 4,800 points at sixteen each, over the old limit of 1,024.
let (p, r) = bumps(300);
assert!(extrude(&p, r, Extrude::default(), front()).is_ok());
assert!(inflate(&p, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok());
assert!(plane(&p, r, front()).is_ok());
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok());
assert!(inflate(&p, FillRule::EvenOdd, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok());
assert!(plane(&p, FillRule::EvenOdd, r, front()).is_ok());
// 4,000 curves: too many for a solid, not for Rotate.
let (p, r) = bumps(4000);
assert!(extrude(&p, r, Extrude::default(), front()).unwrap_err().contains("too complex"));
assert_eq!(plane(&p, r, front()).unwrap().len(), 1);
assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err().contains("too complex"));
assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap().len(), 1);
// Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt
// here (1/2000 of its 100 pt size is less).
let mut c = BezPath::new();
c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((50.0, 50.0), 50.0), 0.01));
let (p, r) = shape(&c);
let ring = &rings(&p, 100.0, MAX_SOLID_POINTS).unwrap()[0];
let ring = &rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap()[0];
for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)) {
let mid = a.midpoint(*b);
assert!(50.0 - mid.distance(Point::new(50.0, 50.0)) <= 0.05 + 1e-3, "{a:?} {b:?}");
@@ -352,7 +362,7 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
assert!(r.width() > 0.0);
// A small bump takes a few points; a large curve more than sixteen where it needs them.
let (p, r) = bumps(300);
let small = rings(&p, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len();
let small = rings(&p, FillRule::EvenOdd, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len();
assert!(small <= 300 * 4 + 3, "{small} points for 300 bumps");
assert!(ring.len() > 4 * 16, "a 100 pt circle within 0.05 pt: {}", ring.len());
}
@@ -362,7 +372,7 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() {
#[test]
fn the_edge_index_finds_the_nearest_edge() {
let (p, r) = bumps(200);
let rings = rings(&p, r.width(), MAX_SOLID_POINTS).unwrap();
let rings = rings(&p, FillRule::EvenOdd, r.width(), MAX_SOLID_POINTS).unwrap();
let edges = Edges::new(&rings);
for k in 0..400 {
let t = f64::from(k);
@@ -376,7 +386,7 @@ fn the_edge_index_finds_the_nearest_edge() {
/// straight ahead: the search doesn't stop before it has looked there.
#[test]
fn the_edge_index_looks_along_the_side_from_outside() {
let ring = |b: BezPath| rings(&shape(&b).0, 64.0, MAX_SOLID_POINTS).unwrap().remove(0);
let ring = |b: BezPath| rings(&shape(&b).0, FillRule::EvenOdd, 64.0, MAX_SOLID_POINTS).unwrap().remove(0);
// Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid).
let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))];
let edges = Edges::new(&rings);
@@ -390,7 +400,7 @@ fn the_edge_index_looks_along_the_side_from_outside() {
fn twist_holds_between_0_and_360_degrees() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() };
let at = |twist: f64| extrude(&p, b, Extrude { twist, ..Extrude::default() }, v).unwrap();
let at = |twist: f64| extrude(&p, FillRule::EvenOdd, b, Extrude { twist, ..Extrude::default() }, v).unwrap();
assert_eq!(at(-45.0), at(0.0));
assert_eq!(at(500.0), at(360.0));
assert_ne!(at(45.0), at(0.0));
@@ -408,11 +418,11 @@ fn twisted_walls_face_the_way_they_turn() {
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0)] {
let v = View { rotation_x: x, rotation_y: y, ..front() };
let o = Extrude { twist, ..Extrude::default() };
let got = extrude(&p, b, o, v).unwrap();
let got = extrude(&p, FillRule::EvenOdd, b, o, v).unwrap();
// Expected: walls (and caps) whose outward normal faces the viewer.
let o = o.sanitized();
let camera = Camera::new(v, b, o.depth);
let ring = &rings(&p, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0];
let ring = &rings(&p, FillRule::EvenOdd, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0];
let slices = ((twist / 6.0).ceil() as usize).clamp(8, 64);
let z = |t: f64| o.depth / 2.0 - o.depth * t;
let mut expected = 0;
@@ -456,7 +466,7 @@ fn inflate_twists_tapers_and_opens_its_back() {
let o = Inflate { depth: 60.0, volume: 0.0, ..Inflate::default() };
// From the side, a flat (0 % volume) cushion is the extrusion: front 100 high, the back 50
// at a 50 % taper.
let tapered = inflate(&p, b, Inflate { taper: 0.5, ..o }, side).unwrap();
let tapered = inflate(&p, FillRule::EvenOdd, b, Inflate { taper: 0.5, ..o }, side).unwrap();
let e = extent(&tapered);
assert!(close(e.width(), 60.0, 1e-6) && close(e.height(), 100.0, 1e-6), "{e:?}");
let back: Vec<Point> = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, e.x0, 1e-6)).collect();
@@ -464,18 +474,19 @@ fn inflate_twists_tapers_and_opens_its_back() {
assert!(close(hi - lo, 50.0, 1e-6), "back {}", hi - lo);
// From behind, a 45° twist shows the flat back as a diamond.
let behind = View { rotation_y: 180.0, ..front() };
let twisted = inflate(&p, b, Inflate { twist: 45.0, ..o }, behind).unwrap();
let twisted = inflate(&p, FillRule::EvenOdd, b, Inflate { twist: 45.0, ..o }, behind).unwrap();
assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted));
// Hollow: no back; from behind the inside of the inflated front shows instead of a flat face.
let solid = inflate(&p, b, Inflate { volume: 50.0, ..o }, behind).unwrap();
let solid = inflate(&p, FillRule::EvenOdd, b, Inflate { volume: 50.0, ..o }, behind).unwrap();
assert_eq!(solid.len(), 1, "the flat back alone");
let hollow = inflate(&p, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap();
let hollow = inflate(&p, FillRule::EvenOdd, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap();
assert!(hollow.len() > 100 && hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() < 12));
// With both sides puffed, the back puff is scaled with the taper: lower and smaller.
let both = |taper: f64| extent(&inflate(&p, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap());
let both =
|taper: f64| extent(&inflate(&p, FillRule::EvenOdd, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap());
assert!(both(0.5).width() < both(1.0).width() && both(0.5).height() <= both(1.0).height() + 1e-6);
// Out of range values hold at the documented ends.
let at = |i: Inflate| inflate(&p, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
let at = |i: Inflate| inflate(&p, FillRule::EvenOdd, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap();
assert_eq!(at(Inflate { twist: -30.0, ..o }), at(o));
assert_eq!(at(Inflate { taper: 0.0, ..o }), at(Inflate { taper: 0.01, ..o }));
}
@@ -487,10 +498,43 @@ fn a_flat_twisted_inflate_matches_the_extrusion() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0), (60.0, 200.0)] {
let v = View { rotation_x: x, rotation_y: y, ..front() };
let e = extent(&extrude(&p, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap());
let i = extent(&inflate(&p, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap());
let e = extent(&extrude(&p, FillRule::EvenOdd, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap());
let i = extent(
&inflate(&p, FillRule::EvenOdd, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap(),
);
for (a, c) in [(e.x0, i.x0), (e.y0, i.y0), (e.x1, i.x1), (e.y1, i.y1)] {
assert!(close(a, c, 1e-6), "view ({x}, {y}): extrusion {e:?}, inflate {i:?}");
}
}
}
/// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole
/// (the middle is filled, as the art is drawn); one running the other way does, and so does
/// either under the even-odd rule.
#[test]
fn nonzero_shapes_keep_the_middle_a_same_way_contour_encloses() {
let rect = |r: Rect, clockwise: bool| {
let mut pts = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)];
if clockwise {
pts.reverse();
}
vectorcraft_geom::SubPath::polyline(&pts, true)
};
let (outer, inner) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(25.0, 25.0, 75.0, 75.0));
let nested = |inner_clockwise: bool| PathData::new(vec![rect(outer, false), rect(inner, inner_clockwise)]);
let flat = |p: &PathData, rule: FillRule| area(&plane(p, rule, outer, front()).unwrap());
let (solid, holed) = (100.0 * 100.0, 100.0 * 100.0 - 50.0 * 50.0);
assert!(close(flat(&nested(false), FillRule::NonZero), solid, 1e-6), "the same way round: filled");
assert!(close(flat(&nested(true), FillRule::NonZero), holed, 1e-6), "the other way round: a hole");
assert!(close(flat(&nested(false), FillRule::EvenOdd), holed, 1e-6));
assert!(close(flat(&nested(true), FillRule::EvenOdd), holed, 1e-6));
// A third contour inside the hole is an island again, whichever way it runs.
let island = |clockwise: bool| PathData::new(vec![rect(outer, false), rect(inner, true), rect(Rect::new(40.0, 40.0, 60.0, 60.0), clockwise)]);
for clockwise in [false, true] {
assert!(close(flat(&island(clockwise), FillRule::NonZero), holed + 20.0 * 20.0, 1e-6), "{clockwise}");
}
// Extrude's caps and Inflate follow the same outline.
let caps = |p: &PathData| area(&extrude(p, FillRule::NonZero, outer, Extrude::default(), front()).unwrap());
assert!(caps(&nested(false)) > caps(&nested(true)) + 1000.0);
assert!(inflate(&nested(false), FillRule::NonZero, outer, Inflate::default(), front()).is_ok());
}
+5
View File
@@ -33,6 +33,11 @@ serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `now_ms` (src/lib.rs) reads the page's `performance.now()` with this crate in the web build. It is
# MIT OR Apache-2.0 and already in the build through eframe.
web-time = "1"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
vectorcraft-testkit = { workspace = true }
+69 -3
View File
@@ -153,6 +153,11 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) {
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
// `hidden_chars` holds the marks Show Hidden Characters kept and the text nodes they were laid
// out from. Only the assignment right before the overlay call below puts them back. A frame
// that does not draw the overlay drops them, for example with the option off, with the edges
// hidden, on the Home screen or with no document open.
let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars);
// A document that opened, closed or became active since Home was chosen replaces it.
if app.ui.home.is_some_and(|k| k != crate::menus::home_key(app)) {
app.ui.home = None;
@@ -438,6 +443,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
thread_overlay(app, &painter, &xf);
}
if app.ui.view.hidden_chars {
app.canvas.hidden_chars = hidden_chars;
hidden_chars_overlay(app, &painter, &xf);
}
}
@@ -1530,8 +1536,8 @@ fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let color = Color32::from_rgb(0x4f, 0x9d, 0xff);
let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0));
let kept = &mut app.canvas.hidden_chars;
// `last` holds the marks of the last frame that showed them. The marks of the objects this
// frame doesn't draw are dropped at the end.
// `last` holds the marks of the last frame. The marks of the objects this frame doesn't draw
// are dropped at the end.
let mut last = std::mem::take(&mut kept.marks);
// The inputs are read before any layout: when fonts load during this frame, the next frame
// lays all type out again.
@@ -1569,7 +1575,7 @@ fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
});
}
/// The Show Hidden Characters marks of the text objects drawn in the last frame that showed them
/// The Show Hidden Characters marks of the text objects drawn in the last frame
/// ([`crate::CanvasCache::hidden_chars`]). Each is kept with the object it was laid out from: an
/// edited object is a new `Arc` and is laid out again, and all type is laid out again when the
/// fonts or the hyphenation exceptions change.
@@ -3763,6 +3769,66 @@ mod tests {
assert_ne!(drawn(&mut app, &xf), zoomed, "set in the family that arrived");
}
/// The shapes Type → Show Hidden Characters draws in one canvas frame ([`show`]).
fn hidden_char_marks(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<Shape> {
let blue = Color32::from_rgb(0x4f, 0x9d, 0xff);
shapes(app, ctx)
.into_iter()
.filter(|s| matches!(s, Shape::Circle(c) if c.fill == blue) || matches!(s, Shape::Text(t) if t.fallback_color == blue))
.collect()
}
/// While Show Hidden Characters is on, a canvas frame keeps the marks of the frame before.
/// Turning it off drops the marks with their text nodes, and turning it on again draws the
/// same marks.
#[test]
fn show_hidden_characters_off_releases_the_text_nodes() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let text = json!({"x": 20, "y": 60, "text": "one two\nthree four"});
let id = vectorcraft_doc::NodeId(app.session.execute("text.create", &text).unwrap()["id"].as_u64().unwrap());
let ctx = egui::Context::default();
app.run("type.hiddenCharacters", json!({})).unwrap();
let on = hidden_char_marks(&mut app, &ctx);
assert!(on.len() > 2, "{on:?}");
// Tests running alongside add fonts and set hyphenation exceptions, which lays all type out
// again: frames are drawn until neither changed between two of them.
let kept = (0..100).any(|_| {
let (inputs, marks) = (app.canvas.hidden_chars.inputs.clone(), app.canvas.hidden_chars.marks[&id].1.clone());
hidden_char_marks(&mut app, &ctx);
let after = &app.canvas.hidden_chars;
after.inputs == inputs && std::sync::Arc::ptr_eq(&after.marks[&id].1, &marks)
});
assert!(kept, "a frame keeps the marks of the frame before");
app.run("type.hiddenCharacters", json!({})).unwrap();
assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "off");
assert!(app.canvas.hidden_chars.marks.is_empty(), "no node is kept");
app.run("type.hiddenCharacters", json!({})).unwrap();
assert_eq!(hidden_char_marks(&mut app, &ctx), on, "on again: the same marks");
}
/// Closing the document drops the marks Show Hidden Characters kept with its text nodes, and
/// the same type in a new document shows the same marks.
#[test]
fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
app.run("type.hiddenCharacters", json!({})).unwrap();
let open = |app: &mut VectorcraftApp| {
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "one two\nthree four"})).unwrap();
hidden_char_marks(app, &ctx)
};
let first = open(&mut app);
assert!(first.len() > 2, "{first:?}");
assert_eq!(app.canvas.hidden_chars.marks.len(), 1, "the marks are kept with the text object's node");
app.session.execute("file.close", &json!({})).unwrap();
assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "the Home screen");
assert!(app.canvas.hidden_chars.marks.is_empty(), "no node of the closed document is kept");
assert_eq!(open(&mut app), first, "the same type in a new document");
}
/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off,
/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel
/// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a
+32 -1
View File
@@ -24,6 +24,8 @@
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
//! the host; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless)
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::mpsc::Sender;
use serde_json::{Value, json};
@@ -63,12 +65,41 @@ pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<ControlResponse>,
state: Arc<AtomicU8>,
}
/// A request's state: waiting to run, taken by the app ([`ControlRequest::claim`]), or given up
/// by its caller before that ([`Ticket::give_up`]). It leaves `QUEUED` once, one way or the other.
const QUEUED: u8 = 0;
const STARTED: u8 = 1;
const GIVEN_UP: u8 = 2;
/// The caller's side of a queued [`ControlRequest`].
pub struct Ticket(Arc<AtomicU8>);
impl Ticket {
/// Stop waiting for the request → `true` when it had not started, and so never runs: a caller
/// told "timeout" can then retry without the edit being applied twice, or applied later when
/// nobody expects it (#1725). `false`: the app is running it (or has); its outcome is unknown.
pub fn give_up(&self) -> bool {
self.0.compare_exchange(QUEUED, GIVEN_UP, Ordering::SeqCst, Ordering::SeqCst).is_ok()
}
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx }, rx)
(Self { method: method.into(), params, reply: tx, state: Arc::new(AtomicU8::new(QUEUED)) }, rx)
}
/// The caller's handle on this request, to give it up with.
pub fn ticket(&self) -> Ticket {
Ticket(self.state.clone())
}
/// Take the request to run it → `false` when its caller gave it up meanwhile (don't run it).
pub fn claim(&self) -> bool {
self.state.compare_exchange(QUEUED, STARTED, Ordering::SeqCst, Ordering::SeqCst).is_ok()
}
}
+19 -2
View File
@@ -41,10 +41,27 @@ pub const MESSAGE: &str = "message";
pub(super) const MESSAGE_SPEC: DialogSpec = DialogSpec { ok: None, ..SPEC };
/// Most messages kept for later while a dialog is open ([`tell`]).
const MAX_WAITING: usize = 16;
/// Tell the user `message` (with an optional `detail` line), both in the UI language, in a dialog
/// they close.
/// they close. While another dialog is open (the message comes from work in the background) it
/// waits its turn instead of replacing it: neither what the user was doing there nor the message
/// is lost.
pub fn tell(app: &mut VectorcraftApp, message: &str, detail: &str) {
app.ui.dialog = Some(Dialog::new(MESSAGE, json!({"message": message, "detail": detail})));
let d = Dialog::new(MESSAGE, json!({"message": message, "detail": detail}));
if app.ui.dialog.is_none() {
app.ui.dialog = Some(d);
} else if app.ui.told.len() < MAX_WAITING && !app.ui.dialog.iter().chain(&app.ui.told).any(|t| t.kind == MESSAGE && t.fields == d.fields) {
app.ui.told.push(d);
}
}
/// Show the next message that waited for a dialog to close (each frame).
pub(super) fn show_waiting(app: &mut VectorcraftApp) {
if app.ui.dialog.is_none() && !app.ui.told.is_empty() {
app.ui.dialog = Some(app.ui.told.remove(0));
}
}
/// Closes before running, so a dialog the command opens stays open.
+1
View File
@@ -366,6 +366,7 @@ pub(crate) fn settle(app: &mut VectorcraftApp) {
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
about::show(app, ctx);
settle(app);
confirm::show_waiting(app);
// The kind of dialog shown last frame: a different one (or none) means this one just opened, and
// its first field is to take the keyboard focus (`focus_id`, until a field takes it).
let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending"));
+4 -1
View File
@@ -268,7 +268,10 @@ mod tests {
let written = Written::default();
let w = written.clone();
let services = Services {
download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))),
download: Some(Box::new(move |name: &str, b: &[u8]| {
w.borrow_mut().push((name.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
(with_art(services), written)
+1 -1
View File
@@ -377,7 +377,7 @@ pub(super) fn section_list(ui: &mut egui::Ui, d: &mut Dialog, sections: &[&str],
for s in sections {
let sel = *s == section;
let label = egui::RichText::new(tl!(*s)).size(12.5).color(if sel { t.text_strong } else { t.text });
if ui.add(egui::Button::selectable(sel, label).frame_when_inactive(false).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() {
if ui.add(egui::Button::selectable(sel, label).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() {
d.fields.insert("__section".into(), json!(s));
}
}
+5 -2
View File
@@ -95,7 +95,10 @@ fn the_web_downloads_a_zip() {
let mut app = saved(&dir);
let got: Rc<RefCell<Vec<(String, usize)>>> = Rc::default();
let g = got.clone();
app.services.download = Some(Box::new(move |name: &str, b: &[u8]| g.borrow_mut().push((name.to_string(), b.len()))));
app.services.download = Some(Box::new(move |name: &str, b: &[u8]| {
g.borrow_mut().push((name.to_string(), b.len()));
Ok(())
}));
app.run("ui.packageDialog", json!({})).unwrap();
assert!(!frame(&mut app).contains("Location:"), "no folder to choose");
confirm(&mut app).unwrap();
@@ -124,7 +127,7 @@ fn fonts_left_out_of_the_package_are_named() {
assert!(detail.contains(gone) && !detail.contains("Source Sans 3"), "{detail}");
// The web's download says so too.
app.ui.dialog = None;
app.services.download = Some(Box::new(|_: &str, _: &[u8]| {}));
app.services.download = Some(Box::new(|_: &str, _: &[u8]| Ok(())));
app.run("ui.packageDialog", json!({})).unwrap();
confirm(&mut app).unwrap();
assert!(app.ui.status.contains(gone), "{}", app.ui.status);
@@ -24,7 +24,10 @@ fn app(dir: &std::path::Path, web: bool) -> (VectorcraftApp, Log) {
..Default::default()
};
if web {
services.download = Some(Box::new(move |name: &str, b: &[u8]| l2.borrow_mut().push(format!("download {name} {}", b.len()))));
services.download = Some(Box::new(move |name: &str, b: &[u8]| {
l2.borrow_mut().push(format!("download {name} {}", b.len()));
Ok(())
}));
}
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 60, "height": 40})).unwrap();
@@ -21,7 +21,10 @@ fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) {
let l = log.clone();
let mut services = Services::default();
if web {
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
l.borrow_mut().push((name.to_string(), bytes.to_vec()));
Ok(())
}));
} else {
services.reveal = Some(Box::new(move |path: &str| {
l.borrow_mut().push((path.to_string(), vec![]));
+4 -1
View File
@@ -21,7 +21,10 @@ fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) {
let l = log.clone();
let mut services = Services::default();
if web {
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
l.borrow_mut().push((name.to_string(), bytes.to_vec()));
Ok(())
}));
} else {
services.reveal = Some(Box::new(move |path: &str| {
l.borrow_mut().push((path.to_string(), vec![]));
+2
View File
@@ -3508,6 +3508,8 @@ effect Regular Pravidelný
Serif / Mincho Patkové / mincho
Changes made since it was last saved will be lost. Změny provedené od posledního uložení budou ztraceny.
Can't open “{name}”. „{name}“ nelze otevřít.
Can't save “{name}”. „{name}“ nelze uložit.
Can't export “{name}”. „{name}“ nelze exportovat.
Downloaded {what} Staženo: {what}
Exported {count} file(s) to {folder} Exportováno souborů: {count} do {folder}
Exported {what} Exportováno: {what}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -754,6 +754,8 @@
Do Not Move Nicht verschieben
Do you want to save the changes you made to “{name}”? Möchten Sie die Änderungen an „{name}“ speichern?
Can't open “{name}”. „{name}“ kann nicht geöffnet werden.
Can't save “{name}”. „{name}“ kann nicht gespeichert werden.
Can't export “{name}”. „{name}“ kann nicht exportiert werden.
Document Dokument
Document Color Mode Dokumentfarbmodus
Document Info Dokumentinformationen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -768,6 +768,8 @@
Do Not Move No mover
Do you want to save the changes you made to “{name}”? ¿Quieres guardar los cambios hechos en “{name}”?
Can't open “{name}”. No se puede abrir «{name}».
Can't save “{name}”. No se puede guardar «{name}».
Can't export “{name}”. No se puede exportar «{name}».
Document Documento
Document Color Mode Modo de color del documento
Document Info Información del documento
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -770,6 +770,8 @@
Do Not Move Ne pas déplacer
Do you want to save the changes you made to “{name}”? Voulez-vous enregistrer les modifications apportées à « {name} » ?
Can't open “{name}”. Impossible d’ouvrir « {name} ».
Can't save “{name}”. Impossible d’enregistrer « {name} ».
Can't export “{name}”. Impossible d’exporter « {name} ».
Document Document
Document Color Mode Mode colorimétrique du document
Document Info Informations sur le document
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -485,6 +485,8 @@
Calligraphic Kalligrafikus
Calligraphic Brush Options Kalligrafikus ecset beállításai
Can't open “{name}”. Nem nyitható meg: „{name}”.
Can't save “{name}”. Nem menthető: „{name}”.
Can't export “{name}”. Nem exportálható: „{name}”.
Can't print these settings: see the Summary Ezekkel a beállításokkal nem lehet nyomtatni: lásd az Összegzést
Cancel Mégse
Cancel Key Object Kulcsobjektum megszüntetése
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -768,6 +768,8 @@
Do Not Move Non spostare
Do you want to save the changes you made to “{name}”? Vuoi salvare le modifiche apportate a “{name}”?
Can't open “{name}”. Impossibile aprire «{name}».
Can't save “{name}”. Impossibile salvare «{name}».
Can't export “{name}”. Impossibile esportare «{name}».
Document Documento
Document Color Mode Metodo colore documento
Document Info Informazioni documento
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1223,6 +1223,8 @@
Divide Vertically Into 垂直方向に分割
Do you want to save the changes you made to “{name}”? 「{name}」への変更を保存しますか?
Can't open “{name}”. 「{name}」を開けません。
Can't save “{name}”. 「{name}」を保存できません。
Can't export “{name}”. 「{name}」を書き出せません。
Document ドキュメント
Document Ink Options ドキュメントのインキオプション
Document JSON ドキュメント JSON
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1218,6 +1218,8 @@
Divide Vertically Into 세로로 나누기
Do you want to save the changes you made to “{name}”? “{name}”의 변경 내용을 저장하시겠습니까?
Can't open “{name}”. “{name}”을(를) 열 수 없습니다.
Can't save “{name}”. “{name}”을(를) 저장할 수 없습니다.
Can't export “{name}”. “{name}”을(를) 내보낼 수 없습니다.
Document 문서
Document Ink Options 문서 잉크 옵션
Document JSON 문서 JSON
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3391,6 +3391,8 @@ effect Regular Normalny
Serif / Mincho Szeryfowe / mincho
Changes made since it was last saved will be lost. Zmiany wprowadzone od ostatniego zapisu zostaną utracone.
Can't open “{name}”. Nie można otworzyć „{name}”.
Can't save “{name}”. Nie można zapisać „{name}”.
Can't export “{name}”. Nie można wyeksportować „{name}”.
Downloaded {what} Pobrano {what}
Exported {count} file(s) to {folder} Wyeksportowane pliki do {folder}: {count}
Exported {what} Wyeksportowano {what}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -692,6 +692,8 @@
Divide Vertically Into Dividir Verticalmente Em
Do you want to save the changes you made to “{name}”? Deseja salvar as alterações feitas em “{name}”?
Can't open “{name}”. Não é possível abrir “{name}”.
Can't save “{name}”. Não é possível salvar “{name}”.
Can't export “{name}”. Não é possível exportar “{name}”.
Document Documento
Document Color Mode Modo de Cor do Documento
Document Info Informações do Documento
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -766,6 +766,8 @@
Do Not Move Не перемещать
Do you want to save the changes you made to “{name}”? Сохранить изменения, внесённые в «{name}»?
Can't open “{name}”. Не удаётся открыть «{name}».
Can't save “{name}”. Не удаётся сохранить «{name}».
Can't export “{name}”. Не удаётся экспортировать «{name}».
Document Документ
Document Color Mode Цветовой режим документа
Document Info Сведения о документе
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -762,6 +762,8 @@
Do Not Move Не переміщати
Do you want to save the changes you made to “{name}”? Зберегти зміни, внесені до «{name}»?
Can't open “{name}”. Не вдається відкрити «{name}».
Can't save “{name}”. Не вдається зберегти «{name}».
Can't export “{name}”. Не вдається експортувати «{name}».
Document Документ
Document Color Mode Колірний режим документа
Document Info Відомості про документ
Can't render this file because it contains an unexpected character in line 713 and column 40.
+2
View File
@@ -401,6 +401,8 @@
Calligraphic Thư pháp
Calligraphic Brush Options Tùy chọn cọ thư pháp
Can't open “{name}”. Không thể mở “{name}”.
Can't save “{name}”. Không thể lưu “{name}”.
Can't export “{name}”. Không thể xuất “{name}”.
Can't print these settings: see the Summary Không thể in các cài đặt này: xem Tóm tắt
Cancel Hủy
Cancel Key Object Hủy đối tượng chính
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
401
402
403
404
405
406
407
408
+2
View File
@@ -723,6 +723,8 @@
Do Not Move 不移动
Do you want to save the changes you made to “{name}”? 要存储对“{name}”所做的更改吗?
Can't open “{name}”. 无法打开“{name}”。
Can't save “{name}”. 无法保存“{name}”。
Can't export “{name}”. 无法导出“{name}”。
Document 文档
Document Color Mode 文档颜色模式
Document Info 文档信息
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -741,6 +741,8 @@
Do Not Move 不移動
Do you want to save the changes you made to “{name}”? 要儲存對「{name}」所做的變更嗎?
Can't open “{name}”. 無法開啟「{name}」。
Can't save “{name}”. 無法儲存「{name}」。
Can't export “{name}”. 無法匯出「{name}」。
Document 文件
Document Color Mode 文件色彩模式
Document Info 文件資訊
Can't render this file because it contains an unexpected character in line 6 and column 47.
+118 -8
View File
@@ -167,6 +167,29 @@ pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) {
dialogs::confirm::tell(app, &message, &crate::i18n::message(crate::i18n::current(), e));
}
/// A save or an export that didn't write `path`: say so in a dialog that names the file and the
/// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which
/// the caller's error becomes, is replaced by the next message, and a document that wasn't saved
/// must not look saved: it stays modified, and what was at `path` is as it was. Not for a request
/// an agent sent and waits on: the error is its reply.
pub(crate) fn report_write_error(app: &mut VectorcraftApp, export: bool, path: &str, e: &str) {
if app.ui.answering_control {
return;
}
let name = fileio::file_name(path);
let message = if export { tl!("Can't export “{name}”.") } else { tl!("Can't save “{name}”.") };
dialogs::confirm::tell(app, &crate::i18n::fmt(message, &[("name", &name)]), &crate::i18n::message(crate::i18n::current(), e));
}
/// [`write_to`], a failure reported ([`report_write_error`]).
pub(crate) fn write_reporting(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> {
let r = write_to(&mut app.services, path, bytes);
if let Err(e) = &r {
report_write_error(app, false, path, e);
}
r
}
/// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). On
/// the desktop a document is decoded off the UI thread ([`crate::opening`]): the result is then
/// `{background: true, status}`, and the document opens in its tab (or the failure is reported)
@@ -239,8 +262,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option<String>) -> Resu
/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`.
pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
if let Some(dl) = services.download.as_mut() {
dl(&fileio::file_name(path), bytes);
return Ok(());
return dl(&fileio::file_name(path), bytes);
}
services.write.as_mut().ok_or("no file writer")?(path, bytes)
}
@@ -260,7 +282,7 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name:
pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })?
}
};
write_to(&mut app.services, &path, bytes)?;
write_reporting(app, &path, bytes)?;
Ok(path)
}
@@ -516,8 +538,10 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String>
}
.map_err(|e| e.to_string())
};
let failed = target.clone().unwrap_or_default();
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
let r = r?;
// Not written: the document keeps its file, format and modified state, and the user is told.
let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?;
// The document may have closed meanwhile: the file is written all the same.
done.complete(&mut app.session, uid);
let path = r["path"].as_str().unwrap_or_default().to_string();
@@ -602,8 +626,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
Ok(out)
};
let enabled = app.session.prefs.background_export;
let mut r = background::run(app, enabled, label, None, work, |app, r| {
let r = r?;
let failed = target.clone();
let mut r = background::run(app, enabled, label, None, work, move |app, r| {
let r = r.inspect_err(|e| report_write_error(app, true, &failed, e))?;
let what = match r["files"].as_array() {
Some(files) => format!("{} files", files.len()),
None => r["path"].as_str().unwrap_or_default().to_string(),
@@ -673,7 +698,7 @@ pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut par
Some(p) => p,
None => target_path(app, None, ext)?,
};
write_to(&mut app.services, &path, &bytes)?;
write_reporting(app, &path, &bytes)?;
Ok((path, v))
}
@@ -768,7 +793,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) ->
} else if let Some(download) = app.services.download.as_mut() {
for (name, data) in &returned {
let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?;
download(name, &bytes);
download(name, &bytes)?;
}
let what = match returned.as_slice() {
[(name, _)] => (*name).to_string(),
@@ -830,6 +855,91 @@ mod tests {
assert!(app.ui.dialog.is_none());
}
/// #1685: a save or an export that can't write its file says so in a dialog that names the
/// file and the reason and stays until it is closed; the document stays modified and keeps
/// the file it had. An agent's request only gets the error, and a message that arrives while
/// another dialog is open waits for it to close.
#[test]
fn a_save_that_cannot_write_is_reported_in_a_dialog_that_stays() {
let refuse: crate::WriteFn = Box::new(|p: &str, _: &[u8]| Err(format!("{p}: Access is denied. (os error 5)")));
let mut app = VectorcraftApp::new(Session::new(), Services { write: Some(refuse), ..Default::default() });
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
let unsaved = |app: &VectorcraftApp| {
let st = app.session.doc().unwrap();
(st.is_dirty(), st.path.clone())
};
for (command, path, message) in [
("file.save", "/docs/Poster.vectorcraft", "Can't save “Poster.vectorcraft”."),
("file.saveAs", "/docs/Poster.vectorcraft", "Can't save “Poster.vectorcraft”."),
("file.saveCopy", "/docs/Copy.vectorcraft", "Can't save “Copy.vectorcraft”."),
("file.exportAs", "/docs/Poster.png", "Can't export “Poster.png”."),
("file.export.pdf", "/docs/Poster.pdf", "Can't save “Poster.pdf”."),
] {
let e = app.run(command, json!({"path": path})).unwrap_err();
assert!(e.contains("Access is denied"), "{command}: {e}");
let d = app.ui.dialog.take().unwrap_or_else(|| panic!("{command}: a dialog says so"));
assert_eq!((d.kind.as_str(), d.str("message").as_str()), (dialogs::confirm::MESSAGE, message), "{command}");
assert_eq!(d.str("detail"), format!("{path}: Access is denied. (os error 5)"), "{command}");
assert_eq!(unsaved(&app), (true, None), "{command}");
}
// An agent waiting on its request gets the error as its reply, and no dialog.
app.ui.answering_control = true;
assert!(app.run("file.save", json!({"path": "/docs/Poster.vectorcraft"})).is_err());
app.ui.answering_control = false;
assert!(app.ui.dialog.is_none() && app.ui.told.is_empty());
// Under another dialog (a background save that fails later) the message waits its turn,
// once, and is drawn when that dialog is closed.
dialogs::confirm::ask(&mut app, "Delete the swatch?", "", "edit.undo", json!({}));
for _ in 0..2 {
report_write_error(&mut app, false, "/docs/Poster.vectorcraft", "the disk is full");
}
assert_eq!((app.ui.dialog.as_ref().map(|d| d.kind.as_str()), app.ui.told.len()), (Some(dialogs::confirm::KIND), 1));
app.ui.dialog = None;
for _ in 0..2 {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(&mut app, ui.ctx()));
out.textures_delta.clear();
}
let d = app.ui.dialog.as_ref().expect("the message is shown, and stays");
assert_eq!(
(d.kind.as_str(), d.str("message").as_str(), d.str("detail").as_str()),
(dialogs::confirm::MESSAGE, "Can't save “Poster.vectorcraft”.", "the disk is full")
);
assert!(app.ui.told.is_empty());
}
/// #1693: a browser download that can't be started is a failed save: the document stays
/// modified and without a file, and Save Changes doesn't close it.
#[test]
fn a_download_that_cannot_start_leaves_the_document_unsaved_and_open() {
let started = Rc::new(RefCell::new(false));
let ok = started.clone();
let download: crate::DownloadFn =
Box::new(move |_: &str, _: &[u8]| if *ok.borrow() { Ok(()) } else { Err("the browser refused the download".into()) });
let mut app = VectorcraftApp::new(Session::new(), Services { download: Some(download), ..Default::default() });
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
let unsaved = |app: &VectorcraftApp| {
let st = app.session.doc().unwrap();
(st.is_dirty(), st.path.clone())
};
let e = save(&mut app, SaveMode::Save, &json!({"path": "Poster.vectorcraft"}), false).unwrap_err();
assert!(e.contains("the browser refused the download"), "{e}");
assert_eq!(unsaved(&app), (true, None));
// Save Changes › Save: not saved, so not closed.
let (i, uid) = (app.session.active_index().unwrap(), app.session.doc().unwrap().uid);
app.ui.dialog = Some(crate::state::Dialog::new(crate::unsaved::KIND, json!({"index": i, "uid": uid, "name": "Poster", "then": "close"})));
app.session.doc_mut().unwrap().path = Some("Poster.vectorcraft".into());
assert!(crate::unsaved::confirm(&mut app).is_err());
assert_eq!((app.session.documents().len(), app.session.doc().unwrap().is_dirty()), (1, true));
// Once the download starts, the save counts.
*started.borrow_mut() = true;
save(&mut app, SaveMode::Save, &json!({}), false).unwrap();
assert!(!app.session.doc().unwrap().is_dirty());
}
fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec<u8> {
let v = app.session.execute(cmd, &p).unwrap();
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
+29 -9
View File
@@ -162,7 +162,7 @@ use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::file_access::{self, AutomationRoots};
use vectorcraft_engine::{Session, ViewInfo};
pub use control::{ControlRequest, ControlResponse};
pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket};
pub use state::{UiState, View};
pub use sysclip::{ClipboardProbeFactory, SystemClipboard};
@@ -188,7 +188,9 @@ pub type PickSave = Box<dyn FnMut(&FilePick) -> Option<String>>;
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
/// Starts a browser download of the bytes under a file name; an error when it couldn't be started
/// (the save or export then fails as a refused write does: #1693).
pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
/// Opens a URL in the system browser.
pub type OpenUrlFn = Box<dyn FnMut(&str)>;
@@ -282,8 +284,8 @@ pub struct CanvasCache {
pub slices: Option<SliceCache>,
/// The print tiling's pages for (document uid, revision) (View → Show Print Tiling).
pub print_tiling: Option<PrintTilingCache>,
/// The marks Type → Show Hidden Characters drew on each text object in the last frame that
/// showed them.
/// The marks Type → Show Hidden Characters drew on each text object in the last frame. A frame
/// that does not draw them drops them ([`canvas::show`]).
pub hidden_chars: canvas::HiddenChars,
/// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds).
pub selection_box: Option<((u64, u64, bool), Option<vectorcraft_doc::OrientedBox>)>,
@@ -769,11 +771,19 @@ impl VectorcraftApp {
let mut handled = 0;
while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME {
let Ok(req) = rx.try_recv() else { break };
// Its caller stopped waiting (the app was busy or its window not drawn for too long)
// and was told it won't run: an edit applied now would come from nowhere (#1725).
if !req.claim() {
continue;
}
handled += 1;
let reply = req.reply.clone();
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
let roots = self.automation_roots.clone();
let outcome = match file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))) {
self.ui.answering_control = true;
let answer = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req)));
self.ui.answering_control = false;
let outcome = match answer {
Ok(outcome) => outcome,
Err(msg) => {
self.picks.recover();
@@ -954,6 +964,8 @@ impl VectorcraftApp {
}
}
/// Returns the milliseconds since the Unix epoch, or in the web build the milliseconds since the
/// first call, read from the page's `performance.now()`.
pub fn now_ms() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -962,7 +974,10 @@ pub fn now_ms() -> f64 {
}
#[cfg(target_arch = "wasm32")]
{
0.0
use std::sync::LazyLock;
use web_time::Instant;
static START: LazyLock<Instant> = LazyLock::new(Instant::now);
START.elapsed().as_secs_f64() * 1000.0
}
}
@@ -977,8 +992,10 @@ impl VectorcraftApp {
self.ui.recent_fonts.get(..n).unwrap_or(&self.ui.recent_fonts)
}
/// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]). The
/// canvas and the transform fields read it every frame: it is measured once per revision.
/// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]); with
/// anchors selected with Direct Selection, the box around them square to the page, which
/// `object.setBounds` then edits (#1824). The canvas and the transform fields read it every
/// frame: it is measured once per revision.
pub fn selection_box(&mut self) -> Option<vectorcraft_doc::OrientedBox> {
let st = self.session.active()?;
let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds);
@@ -987,7 +1004,10 @@ impl VectorcraftApp {
{
return b;
}
let b = self.session.transform_box(&st.selection.objects);
let b = match st.selection.anchor_bounds(&st.doc, self.session.prefs.use_preview_bounds) {
Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)),
None => self.session.transform_box(&st.selection.objects),
};
self.canvas.selection_box = Some((key, b));
b
}
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more