Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Docs: docs/target-app-parity.md.
2026-10-14 06:01:10 +07:00
DWAA1660 dfb0ce36bd M14: verify and bound PngSuite corpus downloads (#1729) 2026-10-14 06:01:10 +07:00
158 changed files with 8290 additions and 1430 deletions

No files matched your search

Generated
+33 -15
View File
@@ -1304,6 +1304,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"
@@ -1760,9 +1766,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"
@@ -1776,9 +1782,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",
@@ -1788,28 +1794,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]]
@@ -1820,9 +1826,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",
@@ -3833,6 +3839,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"
@@ -4784,6 +4800,7 @@ dependencies = [
"vectorcraft-geom",
"vectorcraft-testkit",
"vectorcraft-text",
"weezl",
]
[[package]]
@@ -4959,6 +4976,7 @@ dependencies = [
"vectorcraft-three-d",
"vectorcraft-tools",
"wat",
"web-time",
]
[[package]]
@@ -4986,7 +5004,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",
@@ -5001,7 +5019,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",
+3 -1
View File
@@ -61,7 +61,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"
krilla = "0.8"
# Without `simple-text` (its text shaping, the only user of rustybuzz and ttf-parser, which are
# unmaintained, #1730): the PDF writer places glyphs itself. A test of the PDF crate turns it on.
krilla = { version = "0.8", default-features = false, features = ["raster-images"] }
skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.5"
+10 -1
View File
@@ -6,7 +6,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 (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 (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
@@ -125,8 +125,14 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729).
| Date | What landed |
|---|---|
| 2026-10-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). |
| 2026-10-14 | Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767). Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744). Export for Screens and the Asset Export panel offer TIFF (#1632). SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870). DXF export writes the VPORT view direction once (#1789). Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695). Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717). Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683). A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694). krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875). |
| 2026-10-14 | Image Trace: Shapes finds rounded rectangles and shapes in blurred and scanned art (98 to 100% of the plain-shape inputs, from 57 to 62%; #1845), square-ended lines get square caps (Outline's largest distance 2.94 px to 0.88; #1846), each documented preset has an enhanced version with Auto Grouping and Shapes that measures no worse than the preset (#1847), and blurred or noisy linear gradients trace as one gradient-filled path instead of 3 to 56 (#1848) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). No headline number moves. |
| 2026-10-13 | Synthetic control input has finite click/drag counts and pending-event limits, checked before queue mutation; control-channel bursts yield after 64 requests per frame (#1709). |
| 2026-10-14 | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). A filled Pencil path keeps a stroke of None (#1804, part of #1679). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Select › Same matches type on its characters' fill and stroke (#1746, #1737). Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787). Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757). Slider rails show on Medium Dark (#1808, #1662). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). Control replies are written through a 64 KiB buffer: a 10 MB `document.inspect` reply over loopback on Windows takes 0.25 s instead of 36 s (#1684). MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739). Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). No headline number movLine truncated
| 2026-10-13 | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565). SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596). Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568). The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598). The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601). SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567). PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585). The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584). GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613). Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570). Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590). The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560). Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616). Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score. Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571). Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel kLine truncated
| 2026-10-13 | Control sockets close after bounded idle, partial-line and reply-write waits, returning their connection slot (#1726); existing command processing timeout remains separate. |
@@ -162,6 +168,9 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
| 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. |
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
| 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. |
| 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. |
| 2026-10-10 | minor | Method aligned with the standard, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away |
+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\"}");
-137
View File
@@ -1,137 +0,0 @@
//! Format menu on macOS save sheets (#866). rfd's Cocoa backend combines the extensions of
//! every filter but doesn't show their names. The accessory uses the same filters and updates
//! the filename, whose extension already determines the format in the save command.
//!
//! AppKit owns the accessory until the sheet closes; its target is the accessory itself. The
//! panel reference is weak, so closing the sheet releases both without a retain cycle.
//! Native view handles and Objective-C subclassing require the scoped unsafe allowance.
#![allow(unsafe_code)]
use std::path::Path;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSObjectProtocol, NSPoint, NSRect, NSSize, NSString};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
struct Ivars {
panel: Weak<NSSavePanel>,
filters: Vec<Filter>,
}
define_class!(
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
#[unsafe(super = NSView)]
#[thread_kind = MainThreadOnly]
#[ivars = Ivars]
#[name = "VectorcraftSaveFormatAccessory"]
struct FormatAccessory;
// SAFETY: NSObjectProtocol has no additional requirements.
unsafe impl NSObjectProtocol for FormatAccessory {}
impl FormatAccessory {
// SAFETY: AppKit's target/action sends the popup as the single object argument.
#[unsafe(method(chooseFormat:))]
fn choose_format(&self, sender: &NSPopUpButton) {
let Some(panel) = self.ivars().panel.load() else { return };
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
let Some((_, extensions)) = self.ivars().filters.get(index) else { return };
let Some(extension) = extensions.first() else { return };
let name = panel.nameFieldStringValue().to_string();
let new = filename(&name, extension, &self.ivars().filters);
panel.setNameFieldStringValue(&NSString::from_str(&new));
}
}
);
/// The suggested file's type, or the request's default (the first filter).
fn initial_format(name: &str, filters: &[Filter]) -> usize {
let extension = Path::new(name).extension().and_then(|e| e.to_str());
filters.iter().position(|(_, extensions)| extension.is_some_and(|e| extensions.iter().any(|x| e.eq_ignore_ascii_case(x)))).unwrap_or(0)
}
/// Replace a known format suffix; keep other dots (e.g. a version number) as part of the name.
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
if name.is_empty() {
return name.to_owned();
}
let path = Path::new(name);
if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| filters.iter().any(|(_, extensions)| extensions.iter().any(|x| e.eq_ignore_ascii_case(x))))
{
path.with_extension(extension).to_string_lossy().into_owned()
} else {
format!("{name}.{extension}")
}
}
/// Add a Format menu to the save sheet rfd just began on `window`. Open panels and requests
/// without several named file types are left to rfd.
pub fn attach(window: &winit::window::Window, pick: &FilePick) {
let Some(mtm) = MainThreadMarker::new() else { return };
let filters: Vec<_> = pick.filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
if filters.len() < 2 {
return;
}
let Ok(handle) = window.window_handle() else { return };
let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return };
// SAFETY: winit keeps its content view alive while this window reference is in use.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let Some(sheet) = view.window().and_then(|w| w.attachedSheet()) else { return };
let Some(panel) = sheet.downcast_ref::<NSSavePanel>() else { return };
let selected = initial_format(&pick.name, &filters);
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: NSRect::new(NSPoint::ZERO, NSSize::new(360.0, 38.0))] };
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
label.setFrame(NSRect::new(NSPoint::new(0.0, 10.0), NSSize::new(80.0, 20.0)));
let popup =
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(82.0, 6.0), NSSize::new(278.0, 28.0)), false);
for (name, _) in &accessory.ivars().filters {
popup.addItemWithTitle(&NSString::from_str(i18n::t(name)));
}
// The filter list comes from the built-in format registry, small enough for NSInteger.
if let Ok(index) = isize::try_from(selected) {
popup.selectItemAtIndex(index);
}
// SAFETY: the accessory owns the popup and AppKit keeps the accessory while the sheet is
// open. The selector above has exactly the target/action signature expected by NSControl.
unsafe {
popup.setTarget(Some(&accessory));
popup.setAction(Some(sel!(chooseFormat:)));
accessory.addSubview(&label);
accessory.addSubview(&popup);
}
panel.setExtensionHidden(false);
panel.setAccessoryView(Some(&accessory));
}
#[cfg(test)]
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft", "drawcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
#[test]
fn current_suffix_selects_the_format_and_an_unknown_one_uses_the_default() {
for (name, index) in [("Untitled-1", 0), ("旧形式.drawcraft", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0)] {
assert_eq!(initial_format(name, FILTERS), index);
}
}
#[test]
fn changing_format_keeps_the_name_and_avoids_duplicate_extensions() {
for (name, expected) in
[("図面 1.vectorcraft", "図面 1.svg"), ("a.PDF", "a.svg"), ("art", "art.svg"), ("art.v2", "art.v2.svg"), ("art.svg", "art.svg"), ("", "")]
{
assert_eq!(filename(name, "svg", FILTERS), expected);
}
}
}
+8 -4
View File
@@ -35,8 +35,6 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
@@ -95,6 +93,11 @@ impl eframe::App for App {
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
// Preferences are written when confirmed, not only at exit: a session ended any other way
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
if self.app.take_prefs_changed() {
file_access::unconfined(|| save_prefs(&self.app));
}
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
@@ -480,9 +483,10 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(pick) => {
PickRequest::Save(_) => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
let picked = dialog.save_file();
mac_save_format::attach(window, &pick);
Box::pin(async move { picked.await.iter().map(path).collect() })
}
PickRequest::Folder => {
+15 -2
View File
@@ -296,12 +296,23 @@ const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
const MAX_NODES: usize = 500_000;
/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
const MAX_LAYER_PIXELS: u64 = 512 << 20;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
read_within(bytes, limits, MAX_LAYER_PIXELS)
}
/// [`read`], its pixel layers holding at most `pixels` pixels together.
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
Reader::new(&s, &mut archive).document()
let mut reader = Reader::new(&s, &mut archive);
reader.pixels_left = pixels;
reader.document()
}
pub(crate) struct Reader<'s, 'a, 'b> {
@@ -311,11 +322,13 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
pub(crate) pixels_left: u64,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() }
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS }
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
+43 -1
View File
@@ -105,10 +105,17 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
if u64::from(w) * u64::from(h) > MAX_PIXELS {
let pixels = u64::from(w) * u64::from(h);
if pixels > MAX_PIXELS {
r.warn("pixel layers larger than 64 megapixels");
return Ok(None);
}
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
let Some(left) = r.pixels_left.checked_sub(pixels) else {
r.warn("pixel layers beyond the pixels one document may hold");
return Ok(None);
};
r.pixels_left = left;
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
@@ -404,6 +411,41 @@ mod tests {
}
}
/// Pixel layers of one flat colour: nothing in the archive but `doc.dat` (state 2 is a tile
/// of 0xFF). Written by hand for the reader, like [`fixture`].
fn flat_layers(count: u32, side: i32) -> Vec<u8> {
let layer = |i: u32| {
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"BmpW"), F::I32(side)), (tag(b"BmpH"), F::I32(side))];
for c in 1..=4 {
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![2])));
}
F::Def(10 + i, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap))])
};
let spread = F::Def(1, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared((0..count).map(layer).collect()))]);
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
synth::container(&[("doc.dat", &doc, Method::Zlib)], None)
}
/// #1704: the pixels of all pixel layers together are bounded, also when the archive holds
/// no data for them: the layers past the allowance are left out with a note.
#[test]
fn pixel_layers_share_one_allowance_for_the_whole_document() {
let file = flat_layers(5, 16);
let layers = |pixels: u64| {
let doc = crate::model::read_within(&file, crate::Limits::default(), pixels).unwrap();
let n = doc.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count();
(n, doc.warnings)
};
assert_eq!(layers(5 * 256), (5, vec![]));
let (kept, warnings) = layers(3 * 256 + 255);
assert_eq!(kept, 3);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("pixel layers beyond the pixels one document may hold"), "{warnings:?}");
assert_eq!(crate::read(&file, crate::Limits::default()).unwrap().spreads[0].nodes.len(), 5);
}
#[test]
fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() {
let bytes = png();
+89 -5
View File
@@ -16,6 +16,7 @@ mod defaults;
pub mod track;
mod warp;
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -557,10 +558,11 @@ fn tolerance(scale: f64) -> f64 {
(0.02 * scale).clamp(0.01, 0.25)
}
/// Filled art for one brushed stroke along `bp`. Pieces are in document space with id 0; their
/// appearances carry no brushes (so they never recurse). Stroke-level opacity/blend are the
/// caller's job (`StrokeLayer::opacity`).
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
/// Filled art for one brushed stroke along `bp`, in `doc`. Pieces are in document space with id
/// 0, and never draw a brushed stroke (so they never recurse): their own strokes carry no brush,
/// and what they draw by name that would draw one is left out ([`leave_out_brushed`]).
/// Stroke-level opacity/blend are the caller's job (`StrokeLayer::opacity`).
pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
@@ -573,9 +575,91 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<N
for n in &mut out {
colorize(n, colorization, &stroke.paint);
}
leave_out_brushed(doc, &mut out, &mut Known::new());
out
}
/// A symbol (`false`) or pattern (`true`) of a document, by name: what art draws by name.
type Named<'a> = (bool, &'a str);
/// What [`draws_brushed`] found of each symbol and pattern so far.
type Known = HashMap<(bool, String), bool>;
/// The symbol an instance draws and the patterns that paint `n` (not its children).
fn named(n: &Node) -> impl Iterator<Item = Named<'_>> {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
_ => None,
};
let patterns = vectorcraft_doc::swatches::node_paints(n).filter_map(|p| match p {
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
_ => None,
});
symbol.into_iter().chain(patterns)
}
/// Does drawing the symbol or pattern `def` of `doc` draw a brushed stroke: in its art, or in
/// the art of the symbols and patterns that uses (and so on)? `known`: the answers so far.
fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool {
let key = (def.0, def.1.to_string());
if let Some(&answer) = known.get(&key) {
return answer;
}
let art = |(pattern, name): Named<'_>| -> Vec<&Node> {
match pattern {
false => doc.symbols.iter().filter(|s| s.name == name).take(1).map(|s| &*s.art).collect(),
true => doc.pattern(name).map(|p| p.art.iter().map(|n| &**n).collect()).unwrap_or_default(),
}
};
// Without recursion, each one looked at once: they may use one another without end.
let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def));
let mut answer = false;
while let Some(n) = stack.pop() {
if has_brush(n) {
answer = true;
break;
}
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| &**c));
for other in named(n) {
if seen.insert((other.0, other.1.to_string())) {
stack.extend(art(other));
}
}
}
known.insert(key, answer);
answer
}
/// Leave out of brush pieces `nodes` what they draw by name that draws a brushed stroke
/// ([`draws_brushed`]): such an instance goes, such a pattern paint becomes no paint. A symbol or
/// pattern holding art stroked with the brush itself would draw the brush again, without end.
fn leave_out_brushed(doc: &Document, nodes: &mut Vec<Node>, known: &mut Known) {
fn walk(doc: &Document, n: &mut Node, known: &mut Known) {
for paint in vectorcraft_doc::swatches::node_paints_mut(n) {
if let Paint::Pattern { pattern, .. } = paint
&& draws_brushed(doc, (true, pattern.as_str()), known)
{
*paint = Paint::None;
}
}
if let Some(children) = n.children_mut() {
children.retain(|c| !matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
for c in children.iter_mut() {
walk(doc, Arc::make_mut(c), known);
}
}
if n.extras().mask.is_some()
&& let Some(mask) = n.extras_mut().mask.as_mut()
{
walk(doc, Arc::make_mut(&mut mask.art), known);
}
}
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known)));
for n in nodes.iter_mut() {
walk(doc, n, known);
}
}
/// Pieces for every brushed stroke of `n` (a path or compound path), in appearance order, each
/// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed.
pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
@@ -591,7 +675,7 @@ pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
continue;
}
any = true;
for mut p in stroke_pieces(b, &bp, st) {
for mut p in stroke_pieces(doc, b, &bp, st) {
p.opacity *= st.opacity;
if st.blend != vectorcraft_color::BlendMode::Normal {
p.blend = st.blend;
+90 -38
View File
@@ -37,7 +37,7 @@ fn all_points(n: &Node, out: &mut Vec<Point>) {
#[test]
fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
let out = stroke_pieces(&calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 1);
let b = bounds(&out);
assert!(near(b.height(), 10.0, 0.05), "{b:?}");
@@ -46,30 +46,30 @@ fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() {
#[test]
fn calligraphic_flat_nib_is_thin_along_its_angle() {
let out = stroke_pieces(&calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 2.0, 0.05), "{:?}", bounds(&out));
}
#[test]
fn calligraphic_width_at_45_degrees_matches_ellipse_support() {
let (a, b) = (5.0_f64, 1.0_f64);
let out = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let expect = 2.0 * ((a * a + b * b) / 2.0).sqrt();
assert!(near(bounds(&out).height(), expect, 0.05), "{} vs {expect}", bounds(&out).height());
// A vertical stroke with the same nib has the same width (45° is symmetric).
let v = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
let v = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0));
assert!(near(bounds(&v).width(), expect, 0.05));
}
#[test]
fn calligraphic_scales_with_stroke_weight() {
let out = stroke_pieces(&calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0));
assert!(near(bounds(&out).height(), 6.0, 0.05));
}
#[test]
fn round_nib_is_direction_independent() {
let out = stroke_pieces(&calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0));
let b = bounds(&out);
// Round caps: the bounds grow by the radius on every side.
assert!(near(b.x0, -2.0, 0.05) && near(b.y1, 82.0, 0.05), "{b:?}");
@@ -78,7 +78,7 @@ fn round_nib_is_direction_independent() {
#[test]
fn closed_calligraphic_makes_a_ring() {
let bp = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 6.0), &bp, &stroke(1.0));
let pd = out[0].path_data().unwrap();
assert_eq!(pd.subpaths.len(), 2);
let poly_area = |sp: &vectorcraft_geom::SubPath| {
@@ -102,12 +102,14 @@ fn rect_art(w: f64, h: f64) -> Node {
#[test]
fn art_brush_stretches_to_path_length() {
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0));
let b = bounds(&out);
assert!(near(b.x0, 20.0, 1e-6) && near(b.x1, 220.0, 1e-6), "{b:?}");
assert!(near(b.height(), 4.0, 1e-6));
// 3 pt stroke → 3× wider.
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0));
assert!(near(bounds(&out).height(), 12.0, 1e-6));
}
@@ -120,7 +122,7 @@ fn art_brush_arc_length_mapping_is_linear() {
let mut spacer = frame.clone();
spacer.appearance = Appearance::default().into();
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]);
let out = stroke_pieces(&art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let g = &out[0];
let m = g.children().unwrap()[1].geometric_bounds().unwrap();
assert!(near(m.x0, 150.0, 1e-6) && near(m.x1, 180.0, 1e-6), "{m:?}");
@@ -128,7 +130,8 @@ fn art_brush_arc_length_mapping_is_linear() {
#[test]
fn art_brush_proportional_keeps_aspect() {
let out = stroke_pieces(&art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
let out =
stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 8.0, 1e-6));
}
@@ -139,7 +142,12 @@ fn art_brush_between_guides_keeps_ends() {
Node::path(NodeId(0), shapes::rectangle(Rect::new(80.0, -4.0, 100.0, 4.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let shaft = rect_art(80.0, 2.0);
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(shaft), std::sync::Arc::new(head)]);
let out = stroke_pieces(&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0));
let out = stroke_pieces(
&Document::new(1.0, 1.0),
&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }),
&line(0.0, 0.0, 300.0, 0.0),
&stroke(1.0),
);
let h = out[0].children().unwrap()[1].geometric_bounds().unwrap();
assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}");
}
@@ -154,7 +162,7 @@ fn art_brush_follows_curves() {
bp_open.push(*el);
}
}
let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
let mut pts = vec![];
all_points(&out[0], &mut pts);
assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len());
@@ -178,13 +186,13 @@ fn scatter_brush(spacing: f64) -> Brush {
#[test]
fn scatter_count_and_spacing() {
let out = stroke_pieces(&scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 10);
let centres: Vec<f64> = out.iter().map(|n| n.geometric_bounds().unwrap().center().x).collect();
for (i, c) in centres.iter().enumerate() {
assert!(near(*c, 5.0 + 10.0 * i as f64, 1e-6), "{centres:?}");
}
let out = stroke_pieces(&scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert_eq!(out.len(), 5);
}
@@ -201,8 +209,8 @@ fn scatter_is_deterministic_and_randomised() {
}),
};
let p = line(0.0, 0.0, 300.0, 0.0);
let a1 = stroke_pieces(&b, &p, &stroke(1.0));
let a2 = stroke_pieces(&b, &p, &stroke(1.0));
let a1 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
let a2 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0));
assert_eq!(a1, a2);
let ys: Vec<f64> = a1.iter().map(|n| n.geometric_bounds().unwrap().center().y).collect();
assert!(ys.iter().any(|y| y.abs() > 0.5), "scatter offsets copies: {ys:?}");
@@ -228,21 +236,21 @@ fn pattern_brush(fit: PatternFit, corners: bool) -> Brush {
#[test]
fn pattern_tile_count_by_fit() {
let p = line(0.0, 0.0, 100.0, 0.0);
assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10);
let p2 = line(0.0, 0.0, 104.0, 0.0);
let st = stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
let st = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0));
assert_eq!(st.len(), 10);
assert!(near(bounds(&st).width(), 104.0, 1e-6), "stretch fills the path exactly");
let sp = stroke_pieces(&pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
let sp = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0));
assert_eq!(sp.len(), 10);
assert!(near(sp[0].geometric_bounds().unwrap().width(), 10.0, 1e-6), "add space keeps tile size");
assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5);
}
#[test]
fn pattern_places_corner_tiles() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
let corners = out
.iter()
.filter(
@@ -257,7 +265,7 @@ fn pattern_places_corner_tiles() {
#[test]
fn bristle_makes_translucent_strands() {
let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) };
let out = stroke_pieces(&b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0));
assert!(out.len() >= 5, "{}", out.len());
assert!(out.iter().all(|n| n.opacity < 1.0 && n.opacity > 0.0));
let bb = bounds(&out);
@@ -270,7 +278,7 @@ fn tints_colorization_uses_stroke_colour() {
let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") };
a.colorization = Colorization::Tints;
let b = Brush { name: "t".into(), kind: BrushKind::Art(a) };
let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
assert_eq!(out[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let mut white = rect_art(1.0, 1.0);
white.appearance.set_fill(Paint::solid(Color::WHITE));
@@ -310,7 +318,7 @@ fn brushes_serde_round_trip() {
fn every_default_brush_draws_something() {
let bp = preview_path(120.0, 24.0);
for b in defaults() {
let out = stroke_pieces(b, &bp, &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), b, &bp, &stroke(1.0));
assert!(!out.is_empty(), "{}", b.name);
let bb = bounds(&out);
assert!(bb.width() > 50.0 && bb.width() < 200.0, "{}: {bb:?}", b.name);
@@ -365,20 +373,20 @@ fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
fn pressure_maps_to_size_along_the_stroke() {
let b = pressure_calli(10.0, 4.0);
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
// The stroke weight scales it.
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
// Kept within the valid range: 2 ± 4 never goes below nothing.
let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
}
@@ -389,7 +397,7 @@ fn pressure_varies_angle_and_roundness() {
let horizontal = line(0.0, 0.0, 100.0, 0.0);
let draw = |c: Calligraphic, p: f64| {
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
};
use Variation::{Fixed, Pressure};
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
@@ -399,7 +407,7 @@ fn pressure_varies_angle_and_roundness() {
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
let wide = |p| {
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
};
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
@@ -421,12 +429,18 @@ fn brushes_without_modes_vary_randomly_as_before() {
..old.clone()
};
let bp = preview_path(120.0, 24.0);
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0)));
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &explicit, &bp, &stroke(1.0))
);
// Fixed ignores the variation; Random still draws a different size per stroke.
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
assert!(near(bounds(&stroke_pieces(&Document::new(1.0, 1.0), &fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
// Recorded pressure changes nothing for a brush that doesn't use it.
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])));
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))
);
// Modes are saved by name.
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
@@ -438,7 +452,7 @@ fn junk_pressure_brushes_draw_finite_art() {
let bp = preview_path(120.0, 24.0);
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile));
let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(size, var), &bp, &pressed(profile));
let mut pts = vec![];
for n in &out {
all_points(n, &mut pts);
@@ -461,8 +475,9 @@ fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
#[test]
fn scatter_values_follow_their_modes() {
let bp = line(0.0, 0.0, 400.0, 0.0);
let heights =
|b: &Brush, st: &StrokeLayer| -> Vec<f64> { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() };
let heights = |b: &Brush, st: &StrokeLayer| -> Vec<f64> {
stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect()
};
// Fixed: every copy at 50 %, whatever the second value.
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6)));
// Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt).
@@ -478,7 +493,10 @@ fn scatter_values_follow_their_modes() {
let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") };
assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]);
let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() };
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0)));
assert_eq!(
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)),
stroke_pieces(&Document::new(1.0, 1.0), &unsaved, &bp, &stroke(1.0))
);
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
}
@@ -521,3 +539,37 @@ fn art_colors_list_the_most_used_first() {
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
}
/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed
/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay.
#[test]
fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
use vectorcraft_doc::{PatternDef, Symbol};
let mut doc = Document::new(100.0, 100.0);
let mut brushed = rect_art(10.0, 4.0);
brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) }));
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] {
doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) });
doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)]));
}
let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY });
let painted = |pattern: &str| {
let fill = Paint::Pattern { pattern: pattern.into(), xf: vectorcraft_geom::Affine::IDENTITY };
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 4.0)), Appearance::basic(fill, Paint::None, 0.0))
};
let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into());
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) };
let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(!out.is_empty());
for piece in &out {
let children = piece.children().unwrap();
let symbols: Vec<&str> = children
.iter()
.filter_map(|c| if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { Some(symbol.as_str()) } else { None })
.collect();
assert_eq!(symbols, ["plain"]);
let fills: Vec<Paint> =
children.iter().filter(|c| c.path_data().is_some()).filter_map(|c| c.appearance.fill().map(|f| f.paint.clone())).collect();
assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]);
}
}
+1
View File
@@ -214,6 +214,7 @@ pub fn import(bytes: &[u8], o: &ImportOptions) -> Result<Imported, String> {
let (mut document, mut warnings) = build::Build::new(&d, o).run(layout, natural, ratio)?;
document.units = if o.fit { unit } else { o.unit.unwrap_or(unit) };
warnings.extend(damaged);
document.limit_depth_noting(&mut warnings);
Ok(Imported { document, warnings })
}
+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
+4 -2
View File
@@ -24,8 +24,10 @@ pub struct Clip {
}
/// Deepest nesting of clips, and of groups, an importer records ([`nest`] goes one level deeper
/// for each): deeper ones are left out.
pub const MAX_NEST: usize = 128;
/// for each): deeper ones are left out. As deep as a document's objects may nest
/// ([`crate::MAX_DEPTH`]) under a layer, around the art; clips in groups that deep together are
/// merged by [`Document::limit_depth`].
pub const MAX_NEST: usize = crate::MAX_DEPTH - 2;
/// The warning for clips left out for nesting deeper than [`MAX_NEST`].
pub fn deep_clips() -> String {
+588
View File
@@ -0,0 +1,588 @@
//! How deep objects may nest.
//!
//! The tree is walked by recursion everywhere (drawing, bounds, hit tests, saving, exporting), so
//! a tree of unbounded depth overflows the stack: an abort no guard catches. No document deeper
//! than [`MAX_DEPTH`] is ever kept, nor one whose symbols and patterns draw deeper than
//! [`MAX_DRAWN_DEPTH`] (a symbol holding an instance of itself, or a pattern painting its own
//! tile, draws without end): importers and the native reader merge deeper objects into the group
//! around them and leave such instances and paints out
//! ([`Document::limit_depth`], with [`merged_note`] and [`symbols_note`] as their warnings), and
//! the engine refuses an edit that would nest deeper ([`Document::too_deep`]).
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_color::Paint;
use crate::{Document, Node, NodeKind};
/// The deepest an object may be: a top-level layer is 1 deep, what it holds 2, and so on (the art
/// of an opacity mask is one deeper than the object it masks). A symbol's or pattern's art counts
/// from 1 again.
///
/// A native file writes three levels of JSON for each level of objects, and its reader stops at
/// 128 levels: 32 leaves the deepest object 29 of them for its own data, so every document that
/// can be made can be saved and opened again.
pub const MAX_DEPTH: usize = 32;
/// The deepest an object may be drawn: as [`MAX_DEPTH`], the art of a symbol counted under its
/// instance and a pattern's tile under the object it paints (and what those use, under them).
/// Symbols a few objects deep nest in one another dozens deep within it.
pub const MAX_DRAWN_DEPTH: usize = 4 * MAX_DEPTH;
/// The warning of a file whose objects were nested deeper than [`MAX_DEPTH`].
pub fn merged_note() -> String {
format!("objects nested more than {MAX_DEPTH} deep were merged into the group around them")
}
/// The warning of a file with symbols or patterns that use themselves or are drawn deeper than
/// [`MAX_DRAWN_DEPTH`].
pub fn symbols_note() -> String {
format!(
"symbol instances and pattern paints inside their own symbol or pattern, or drawn more than {MAX_DRAWN_DEPTH} objects deep, were left out"
)
}
/// Run `f` where recursion has room: on a thread of its own with a 16 MB stack (the main thread
/// has 1 MB on Windows), or here where there are no threads (the web) or none can be started. For
/// readers whose recursion is capped but heavy; a panic in `f` goes on to the caller.
pub fn with_deep_stack<T: Send>(f: impl Fn() -> T + Sync) -> T {
#[cfg(not(target_arch = "wasm32"))]
{
let threaded = std::thread::scope(|s| {
let thread = std::thread::Builder::new().stack_size(16 << 20).spawn_scoped(s, &f).ok()?;
Some(thread.join().unwrap_or_else(|panic| std::panic::resume_unwind(panic)))
});
if let Some(done) = threaded {
return done;
}
}
f()
}
/// Is anything in `now` (each `depth` deep) deeper than [`MAX_DEPTH`], or `over` (given its
/// depth)? Without recursion: the trees may be of any depth. With `before` (the same list before
/// an edit, within the caps), only what the edit changed is looked at: a subtree still shared with
/// it, in the same place, is as it was.
fn deeper(now: &[Arc<Node>], before: Option<&[Arc<Node>]>, depth: usize, over: &dyn Fn(&Node, usize) -> bool) -> bool {
type Pending<'a> = Vec<(&'a Node, Option<&'a Node>, usize)>;
fn push<'a>(stack: &mut Pending<'a>, now: &'a [Arc<Node>], before: Option<&'a [Arc<Node>]>, depth: usize) {
for (i, n) in now.iter().enumerate() {
let was = before.and_then(|b| b.get(i));
if !was.is_some_and(|w| Arc::ptr_eq(w, n)) {
stack.push((n, was.map(|w| &**w), depth));
}
}
}
let mut stack = vec![];
push(&mut stack, now, before, depth);
while let Some((n, was, d)) = stack.pop() {
if d > MAX_DEPTH || over(n, d) {
return true;
}
if let Some(ch) = n.children() {
push(&mut stack, ch, was.and_then(Node::children).map(Vec::as_slice), d + 1);
}
if let Some(m) = &n.extras().mask {
let was = was.and_then(|w| w.extras().mask.as_ref()).map(|m| std::slice::from_ref(&m.art));
push(&mut stack, std::slice::from_ref(&m.art), was, d + 1);
}
}
false
}
/// [`deeper`] for the nesting alone.
fn nested_deeper(now: &[Arc<Node>], depth: usize) -> bool {
deeper(now, None, depth, &|_, _| false)
}
/// Merge what is deeper than [`MAX_DEPTH`] in `nodes` (each `depth` deep) into the group around
/// it: a container at the cap is replaced by what it holds (a clipping group's clipping path and
/// an opacity mask too deep are left out); what it held stays hidden or locked as it was. Returns
/// whether anything changed. Recurses no deeper than the cap.
fn limit(nodes: &mut Vec<Arc<Node>>, depth: usize) -> bool {
let mut changed = false;
let mut i = 0;
while let Some(n) = nodes.get_mut(i) {
if !nested_deeper(std::slice::from_ref(n), depth) {
i += 1;
continue;
}
changed = true;
let n = Arc::make_mut(n);
if n.extras().mask.as_ref().is_some_and(|m| nested_deeper(std::slice::from_ref(&m.art), depth + 1)) {
n.extras_mut().mask = None;
}
if depth < MAX_DEPTH {
if let Some(ch) = n.children_mut() {
limit(ch, depth + 1);
}
i += 1;
continue;
}
let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
let (visible, locked) = (n.visible, n.locked);
let mut held = n.children_mut().map(std::mem::take).unwrap_or_default();
if clip && !held.is_empty() {
held.remove(0);
}
for c in &mut held {
if (!visible && c.visible) || (locked && !c.locked) {
let c = Arc::make_mut(c);
c.visible &= visible;
c.locked |= locked;
}
}
// What it held takes its place, and is looked at in its turn.
nodes.splice(i..=i, held);
}
changed
}
/// A symbol (`false`) or a pattern (`true`) of the document, by name: what art draws by name, an
/// instance its symbol's art and a pattern paint its tile's.
type Def<'a> = (bool, &'a str);
/// How deep each symbol's and pattern's art is drawn, the art of the symbols and patterns it uses
/// included (drawn under the object that uses them): `None` for one that uses itself, in its art
/// or through what that uses, or is drawn deeper than [`MAX_DRAWN_DEPTH`].
type Heights<'a> = HashMap<Def<'a>, Option<usize>>;
/// What `n` itself draws by name: the symbol it is an instance of, the patterns that paint it.
fn uses(n: &Node) -> impl Iterator<Item = Def<'_>> {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())),
_ => None,
};
let patterns = crate::swatches::node_paints(n).filter_map(|p| match p {
Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())),
_ => None,
});
symbol.into_iter().chain(patterns)
}
/// [`Heights`] of the document's symbols and patterns. Without recursion: they may use one
/// another in a chain of any length. Their own art is within [`MAX_DEPTH`].
fn heights(doc: &Document) -> Heights<'_> {
// Each one's own depth, and what its art uses with the deepest use of each.
type Own<'a> = (usize, Vec<(Def<'a>, usize)>);
let arts = (doc.symbols.iter().map(|s| ((false, s.name.as_str()), std::slice::from_ref(&s.art))))
.chain(doc.patterns.iter().map(|p| ((true, p.name.as_str()), &p.art[..])));
let mut own: HashMap<Def, Own> = HashMap::new();
for (def, art) in arts {
let (mut deepest, mut used) = (0, HashMap::<Def, usize>::new());
let mut stack: Vec<(&Node, usize)> = art.iter().map(|n| (&**n, 1)).collect();
while let Some((n, d)) = stack.pop() {
deepest = deepest.max(d);
for u in uses(n) {
let at = used.entry(u).or_default();
*at = (*at).max(d);
}
stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1)));
}
// (The first of two of one name is the one drawn.)
own.entry(def).or_insert((deepest, used.into_iter().collect()));
}
// Depth first over what uses what. One met again while it is being worked out (`None` in its
// entry) uses itself.
let mut found: HashMap<Def, Option<Option<usize>>> = HashMap::new();
let starts: Vec<Def> = own.keys().copied().collect();
for start in starts {
if found.contains_key(&start) {
continue;
}
// (which, the next of its uses to look at, the deepest found so far: `None` once it can't be drawn)
let mut path: Vec<(Def, usize, Option<usize>)> = vec![(start, 0, own.get(&start).map(|o| o.0))];
found.insert(start, None);
while let Some(&(def, next, deepest)) = path.last() {
let used = own.get(&def).map_or(&[][..], |o| &o.1);
let Some(&(other, at)) = used.get(next) else {
// Everything it uses is worked out.
let deepest = deepest.filter(|d| *d <= MAX_DRAWN_DEPTH);
found.insert(def, Some(deepest));
path.pop();
if let Some((user, next, held)) = path.last_mut() {
let at = own.get(user).and_then(|o| o.1.get(next.wrapping_sub(1))).map_or(1, |u| u.1);
*held = held.zip(deepest).map(|(h, d)| h.max(at + d));
}
continue;
};
let known = found.get(&other).copied();
if known.is_none() && own.contains_key(&other) {
found.insert(other, None);
}
let Some((_, next, deepest)) = path.last_mut() else { break };
*next += 1;
match known {
// Not in the document: it draws nothing.
None if !own.contains_key(&other) => {}
None => path.push((other, 0, own.get(&other).map(|o| o.0))),
Some(Some(Some(d))) => *deepest = deepest.map(|h| h.max(at + d)),
// It uses itself (it is on the way down here), or can't be drawn.
Some(None | Some(None)) => *deepest = None,
}
}
}
found.into_iter().map(|(def, h)| (def, h.flatten())).collect()
}
/// Can't `used`, by an object `depth` deep, be drawn there?
fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool {
heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH))
}
/// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object):
/// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees
/// within [`MAX_DEPTH`].
fn strip(nodes: &mut Vec<Arc<Node>>, depth: usize, bad: &dyn Fn(Def, usize) -> bool) -> bool {
let any = |n: &Node, d: usize| uses(n).any(|u| bad(u, d));
let before = nodes.len();
nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if bad((false, symbol.as_str()), depth)));
let mut changed = nodes.len() != before;
for n in nodes.iter_mut() {
if !deeper(std::slice::from_ref(n), None, depth, &any) {
continue;
}
changed = true;
let n = Arc::make_mut(n);
if uses(n).any(|u| bad(u, depth)) {
for p in crate::swatches::node_paints_mut(n) {
if matches!(p, Paint::Pattern { pattern, .. } if bad((true, pattern.as_str()), depth)) {
*p = Paint::None;
}
}
}
// An opacity mask whose art is such an instance goes with it.
let mut mask = n.extras().mask.as_ref().map(|m| vec![m.art.clone()]);
if let Some(art) = &mut mask
&& strip(art, depth + 1, bad)
{
match art.pop() {
Some(art) => {
if let Some(m) = n.extras_mut().mask.as_mut() {
m.art = art;
}
}
None => n.extras_mut().mask = None,
}
}
if let Some(ch) = n.children_mut() {
strip(ch, depth + 1, bad);
}
}
changed
}
impl Document {
/// Is anything nested deeper than [`MAX_DEPTH`], or drawn deeper than [`MAX_DRAWN_DEPTH`]
/// (a symbol or pattern that uses itself is)? With `before` (this document before an edit,
/// itself within the caps), only what the edit changed is looked at.
pub fn too_deep(&self, before: Option<&Document>) -> bool {
fn symbol(d: &Document, i: usize) -> Option<&[Arc<Node>]> {
d.symbols.get(i).map(|s| std::slice::from_ref(&s.art))
}
fn pattern(d: &Document, i: usize) -> Option<&[Arc<Node>]> {
d.patterns.get(i).map(|p| &p.art[..])
}
let nested = |_: &Node, _: usize| false;
if (0..self.symbols.len()).any(|i| symbol(self, i).is_some_and(|art| deeper(art, before.and_then(|b| symbol(b, i)), 1, &nested)))
|| (0..self.patterns.len()).any(|i| pattern(self, i).is_some_and(|art| deeper(art, before.and_then(|b| pattern(b, i)), 1, &nested)))
{
return true;
}
if self.symbols.is_empty() && self.patterns.is_empty() {
return deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &nested);
}
let heights = heights(self);
if heights.values().any(Option::is_none) {
return true;
}
// Symbols and patterns draw as deep as before when none changed; else every use of them
// is looked at again.
let same = |x: &[Arc<Node>], y: &[Arc<Node>]| x.len() == y.len() && x.iter().zip(y).all(|(x, y)| Arc::ptr_eq(x, y));
let before = before.filter(|b| {
b.symbols.len() == self.symbols.len()
&& b.patterns.len() == self.patterns.len()
&& b.symbols.iter().zip(&self.symbols).all(|(x, y)| x.name == y.name && Arc::ptr_eq(&x.art, &y.art))
&& b.patterns.iter().zip(&self.patterns).all(|(x, y)| x.name == y.name && same(&x.art, &y.art))
});
let over = |n: &Node, d: usize| uses(n).any(|u| undrawable(&heights, u, d));
deeper(&self.layers, before.map(|b| &b.layers[..]), 1, &over)
}
/// [`Self::limit_depth`] → whether objects were merged, and whether uses of symbols and
/// patterns were left out.
fn limit_depths(&mut self) -> (bool, bool) {
let mut merged = limit(&mut self.layers, 1);
for s in &mut self.symbols {
// The art itself is 1 deep, so it stays the one object it is.
let mut art = vec![s.art.clone()];
if limit(&mut art, 1)
&& let Some(one) = art.pop()
{
s.art = one;
merged = true;
}
}
for p in &mut self.patterns {
merged |= limit(&mut p.art, 1);
}
if self.symbols.is_empty() && self.patterns.is_empty() {
return (merged, false);
}
// The symbols and patterns that can't be drawn lose what their art uses: each is then as
// deep as its own art, and what uses it can be drawn.
let (symbols, patterns): (Vec<usize>, Vec<usize>) = {
let heights = heights(self);
let bad = |def: Def| heights.get(&def).is_some_and(Option::is_none);
(
(0..self.symbols.len()).filter(|i| self.symbols.get(*i).is_some_and(|s| bad((false, s.name.as_str())))).collect(),
(0..self.patterns.len()).filter(|i| self.patterns.get(*i).is_some_and(|p| bad((true, p.name.as_str())))).collect(),
)
};
let mut left_out = !(symbols.is_empty() && patterns.is_empty());
let all = |_: Def, _: usize| true;
for i in symbols {
let Some(s) = self.symbols.get_mut(i) else { continue };
let mut art = vec![s.art.clone()];
strip(&mut art, 1, &all);
// (An instance alone was the whole art: an empty group stands in.)
s.art = art.pop().unwrap_or_else(|| Arc::new(Node::group(s.art.id, vec![])));
}
for i in patterns {
if let Some(p) = self.patterns.get_mut(i) {
strip(&mut p.art, 1, &all);
}
}
// Then the uses drawn too deep where they are.
let mut layers = self.layers.clone();
left_out |= {
let heights = heights(self);
strip(&mut layers, 1, &|used, depth| undrawable(&heights, used, depth))
};
self.layers = layers;
(merged, left_out)
}
/// Bring the document within the caps: merge the objects nested deeper than [`MAX_DEPTH`]
/// into the group around them, and leave out the uses of symbols and patterns that can't be
/// drawn (inside their own symbol or pattern, or deeper than [`MAX_DRAWN_DEPTH`]). What an
/// importer does to what it read. Returns whether anything changed.
pub fn limit_depth(&mut self) -> bool {
let (merged, left_out) = self.limit_depths();
merged || left_out
}
/// [`Self::limit_depth`], adding [`merged_note`] and [`symbols_note`] to an importer's
/// `warnings` for what it did.
pub fn limit_depth_noting(&mut self, warnings: &mut Vec<String>) {
let (merged, left_out) = self.limit_depths();
warnings.extend(merged.then(merged_note));
warnings.extend(left_out.then(symbols_note));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{NodeId, OpacityMask};
/// One path in `levels` groups, each holding a second path beside the next group.
fn nested(levels: usize) -> Arc<Node> {
let mut id = 0;
let mut next = || {
id += 1;
NodeId(id)
};
let path = |id| Arc::new(Node::path(id, Default::default(), Default::default()));
let mut inner = path(next());
for _ in 0..levels {
inner = Arc::new(Node::group(next(), vec![inner, path(next())]));
}
inner
}
fn doc(art: Arc<Node>) -> Document {
let mut d = Document::new(100.0, 100.0);
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.push(art);
}
d
}
#[test]
fn a_document_at_the_cap_is_left_alone() {
// The layer, the groups and the path in them: MAX_DEPTH deep.
let mut d = doc(nested(MAX_DEPTH - 2));
let before = d.clone();
assert!(!d.too_deep(None));
assert!(!d.limit_depth());
assert_eq!(d, before);
}
#[test]
fn deeper_objects_are_merged_into_the_group_around_them() {
for levels in [MAX_DEPTH - 1, MAX_DEPTH + 5, 100_000] {
let mut d = doc(nested(levels));
assert!(d.too_deep(None), "{levels}");
assert!(d.limit_depth(), "{levels}");
assert!(!d.too_deep(None), "{levels}");
// Every path is kept, under the layer and the groups down to the cap.
assert_eq!(d.node_count(), 1 + (MAX_DEPTH - 2) + (levels + 1), "{levels}");
assert!(!d.limit_depth(), "nothing more to merge");
}
}
#[test]
fn a_clipping_path_and_a_mask_too_deep_are_left_out_and_hidden_art_stays_hidden() {
let mut inner = Node::new(NodeId(9000), NodeKind::Group { children: vec![nested(0), nested(0)], clip: true });
inner.visible = false;
let mut art = Arc::new(inner);
for i in 0..MAX_DEPTH {
art = Arc::new(Node::group(NodeId(9001 + i as u64), vec![art]));
}
let mut masked = Node::group(NodeId(8000), vec![]);
masked.extras_mut().mask = Some(Box::new(OpacityMask::new((*nested(MAX_DEPTH)).clone(), true)));
let mut d = doc(art);
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.push(Arc::new(masked));
}
assert!(d.limit_depth());
assert!(!d.too_deep(None));
let mut leaves = vec![];
d.walk(|n| {
if n.children().is_none() {
leaves.push(n.visible);
}
});
assert_eq!(leaves, [false], "the clipping path is gone, what it clipped is still hidden");
assert!(d.node(NodeId(8000)).is_some_and(|n| n.extras().mask.is_none()));
}
#[test]
fn an_edit_is_checked_by_what_it_changed() {
let before = doc(nested(MAX_DEPTH - 2));
let mut wrapped = before.clone();
if let Some(ch) = wrapped.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
let all = std::mem::take(ch);
ch.push(Arc::new(Node::group(NodeId(7000), all)));
}
assert!(wrapped.too_deep(Some(&before)), "the same art, one deeper");
let mut beside = before.clone();
if let Some(ch) = beside.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.push(nested(3));
}
assert!(!beside.too_deep(Some(&before)));
assert!(!before.too_deep(Some(&before)));
}
#[test]
fn symbols_and_patterns_are_limited_too() {
let mut d = Document::new(10.0, 10.0);
d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) });
assert!(d.too_deep(None));
assert!(d.limit_depth());
assert!(!d.too_deep(None));
assert!(d.symbols.first().is_some_and(|s| s.art.children().is_some()), "still one object");
}
fn instance(id: u64, of: &str) -> Arc<Node> {
Arc::new(Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: of.into(), xf: Default::default() }))
}
/// `count` symbols, each `levels` groups around an instance of the next; the last one's of `last`.
fn symbols(count: usize, levels: usize, last: &str) -> Document {
let mut d = doc(instance(1, "s0"));
for i in 0..count {
let mut art = instance(2, &if i + 1 < count { format!("s{}", i + 1) } else { last.to_string() });
for _ in 0..levels {
art = Arc::new(Node::group(NodeId(3), vec![art]));
}
d.symbols.push(crate::Symbol { name: format!("s{i}"), art });
}
d
}
fn instances(d: &Document) -> usize {
let mut n = 0;
let mut count = |x: &Node| n += usize::from(matches!(x.kind, NodeKind::SymbolInstance { .. }));
d.walk(&mut count);
d.symbols.iter().for_each(|s| s.art.walk(&mut count));
n
}
#[test]
fn symbols_in_symbols_are_drawn_only_so_deep() {
// Three objects deep each (two groups and the instance), under the instance on the layer.
let fits = MAX_DRAWN_DEPTH / 3 - 1;
let mut d = symbols(fits, 2, "none");
assert!(!d.too_deep(None) && !d.limit_depth());
let mut d = symbols(fits + 2, 2, "none");
assert!(d.too_deep(None));
let before = instances(&d);
assert!(d.limit_depth() && !d.too_deep(None));
assert!(instances(&d) < before && instances(&d) > 0, "only the instances drawn too deep go");
// A chain of any length is measured, not walked by recursion.
let mut d = symbols(100_000, 0, "none");
assert!(d.too_deep(None) && d.limit_depth() && !d.too_deep(None));
}
#[test]
fn a_symbol_holding_itself_loses_the_instance() {
for (count, last) in [(1, "s0"), (3, "s0"), (3, "s1")] {
let mut d = symbols(count, 1, last);
assert!(d.too_deep(None), "{count} {last}");
let mut warnings = vec![];
d.limit_depth_noting(&mut warnings);
assert_eq!(warnings, [symbols_note()]);
assert!(!d.too_deep(None), "{count} {last}");
assert!(d.symbols.iter().all(|s| s.art.children().is_some()), "the art is still a group");
}
// An edit that makes one hold itself is seen, whatever else stayed as it was.
let before = symbols(2, 1, "none");
let mut after = before.clone();
after.symbols[1].art = Arc::new(Node::group(NodeId(9), vec![instance(8, "s0")]));
assert!(!before.too_deep(None) && after.too_deep(Some(&before)));
}
/// A path painted with the pattern named `with`.
fn painted(id: u64, with: &str) -> Arc<Node> {
let fill = Paint::Pattern { pattern: with.into(), xf: Default::default() };
Arc::new(Node::path(NodeId(id), Default::default(), crate::Appearance::basic(fill, Paint::None, 0.0)))
}
fn pattern_fills(d: &Document) -> usize {
let mut n = 0;
d.visit_paints(&mut |p| n += usize::from(matches!(p, Paint::Pattern { .. })));
n
}
#[test]
fn a_pattern_painting_its_own_tile_loses_the_paint() {
// One that paints its own tile; two that paint each other's; one in a symbol it holds.
let itself = |d: &mut Document| d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "p")]));
let each_other = |d: &mut Document| {
d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")]));
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "p")]));
};
let through_a_symbol = |d: &mut Document| {
d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")]));
d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") });
};
for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] {
let mut d = doc(painted(1, "p"));
make(&mut d);
assert!(d.too_deep(None));
let mut warnings = vec![];
d.limit_depth_noting(&mut warnings);
assert_eq!(warnings, [symbols_note()]);
assert!(!d.too_deep(None));
assert_eq!(pattern_fills(&d), 1, "the object on the layer keeps its pattern");
}
// A pattern whose tile is painted with another is fine.
let mut d = doc(painted(1, "p"));
d.patterns.push(crate::PatternDef::new("p", vec![painted(2, "q")]));
d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")]));
assert!(!d.too_deep(None) && !d.limit_depth());
}
}
+79 -9
View File
@@ -11,6 +11,7 @@ pub mod blend;
pub mod clipnest;
pub mod cmyk;
pub mod corners;
pub mod depth;
pub mod graph;
pub mod hit;
mod id_index;
@@ -30,6 +31,7 @@ pub mod overprint;
pub mod pattern;
pub mod perspective;
mod pixels;
pub use pixels::MAX_RASTER_ALLOC;
pub mod placed_document;
pub mod profiles;
pub mod puppet;
@@ -79,6 +81,7 @@ pub use appearance::{
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use depth::MAX_DEPTH;
pub use graph::{
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
ValueAxisSide,
@@ -773,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 {
@@ -781,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.
@@ -1374,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)
@@ -1458,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 {
@@ -1497,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));
}
}
@@ -1626,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);
+33 -9
View File
@@ -109,10 +109,11 @@ impl Document {
}
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
/// strokes and text runs — in the art, symbol definitions, pattern tiles, graphic styles and the
/// swatches themselves (tint swatches, gradient swatches' stops). `f(colour, link, tint)` may
/// change them and returns true when it did ([`Paint::map_links`]). Only nodes holding a changed
/// paint (and the paths to them) are copied. Returns the number of changed paints.
/// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern
/// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops).
/// `f(colour, link, tint)` may change them and returns true when it did
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
/// copied. Returns the number of changed paints.
pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize {
let mut n = map_trees(&mut self.layers, f);
for s in &mut self.symbols {
@@ -152,11 +153,12 @@ impl Document {
}
impl Document {
/// Visit every paint held by the art, symbol definitions, pattern tiles and graphic styles
/// (fills, strokes and text runs), and the colour of every gradient-mesh point as a solid paint.
/// Visit every paint held by the art (opacity masks included), symbol definitions, pattern
/// tiles and graphic styles (fills, strokes and text runs), and the colour of every
/// gradient-mesh point as a solid paint.
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
let mut tree = |n: &Node| {
n.walk(&mut |m| {
walk_with_masks(n, &mut |m| {
node_paints(m).for_each(&mut *f);
if let NodeKind::Mesh(mesh) = &m.kind {
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
@@ -224,7 +226,7 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
}
/// The paints a node holds itself (not its children): appearance items, then text runs.
fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
let runs = match &n.kind {
NodeKind::Text(t) => t.runs.as_slice(),
_ => &[],
@@ -233,7 +235,7 @@ fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
}
/// Mutable [`node_paints`], in the same order.
fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
let runs = match &mut n.kind {
NodeKind::Text(t) => t.runs.as_mut_slice(),
_ => &mut [],
@@ -280,9 +282,31 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
}
}
}
// Opacity mask art lives outside the tree, but its colours are the object's too (#1735).
if let Some(mask) = n.extras().mask.as_deref()
&& let Some((new, k)) = map_node(&mask.art, f)
{
let m = out.get_or_insert_with(|| n.clone());
if let Some(slot) = m.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
slot.art = Arc::new(new);
count += k;
}
}
out.map(|m| (m, count))
}
/// Visit `n` and its subtree, the art of opacity masks included (mask art after the masked
/// object, before its children).
fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
f(n);
if let Some(mask) = n.extras().mask.as_deref() {
walk_with_masks(&mask.art, f);
}
for c in n.children().into_iter().flatten() {
walk_with_masks(c, f);
}
}
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
+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());
+24 -15
View File
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -160,8 +160,8 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection_or_ids,
trace_set_view
),
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params}]}", always, |_, _| {
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p })).collect::<Vec<_>>() }))
cmd!(query "imageTrace.presets", "Image Trace Presets", [], None, "{} → {presets: [{name, params, enhanced: the params of the preset's enhanced version, null if it has none}]}", always, |_, _| {
Ok(json!({ "presets": tr::presets().into_iter().map(|(n, p)| json!({ "name": n, "params": p, "enhanced": tr::enhanced_preset(n) })).collect::<Vec<_>>() }))
}),
]
}
@@ -657,7 +657,10 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
fn trace_params(p: &Value) -> Result<tr::TraceParams> {
let name = str_param(p, "preset").unwrap_or("Default");
let base = tr::preset(name).ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false);
// (A preset without an enhanced version is traced as it is.)
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
let Some(over) = p.get("params").filter(|v| !v.is_null()) else { return Ok(base) };
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
let (Some(o), Some(src)) = (v.as_object_mut(), over.as_object()) else { return Err(bad("imageTrace.make", "params must be an object")) };
@@ -738,8 +741,9 @@ fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Pain
}
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
/// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is
/// inside it. Without parents, the nodes as they are.
/// inside it (`parents`, as the tracer gives them) in a group with them, in the order they had:
/// abutting paths painted in another order would show a seam where they meet. Without parents,
/// the nodes as they are.
fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec<Arc<Node>> {
let n = nodes.len();
let mut inside: Vec<Vec<usize>> = vec![vec![]; n];
@@ -761,8 +765,14 @@ fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
for &i in order.iter().rev() {
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
let within: Vec<Arc<Node>> = inside.get(i).into_iter().flatten().filter_map(|&c| built.get_mut(c)?.take()).collect();
let made = if within.is_empty() { node } else { Node::group(d.alloc_id(), std::iter::once(Arc::new(node)).chain(within).collect()) };
let mut within: Vec<(usize, Arc<Node>)> = inside.get(i).into_iter().flatten().filter_map(|&c| Some((c, built.get_mut(c)?.take()?))).collect();
let made = if within.is_empty() {
node
} else {
within.push((i, Arc::new(node)));
within.sort_by_key(|m| m.0);
Node::group(d.alloc_id(), within.into_iter().map(|m| m.1).collect())
};
if let Some(slot) = built.get_mut(i) {
*slot = Some(Arc::new(made));
}
@@ -778,12 +788,11 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
let mut params = trace_params(p)?;
let view = view_param(C, p)?;
let params_json = serde_json::to_value(&params).map_err(|e| EngineError::Other(e.to_string()))?;
// The preset's name if the parameters are exactly that preset's, else "Custom".
// The preset's name if the parameters are exactly that preset's or its enhanced version's,
// else "Custom".
let named = str_param(p, "preset").unwrap_or("Default");
let preset_label = match tr::preset(named) {
Some(b) if serde_json::to_value(&b).ok().as_ref() == Some(&params_json) => named.to_string(),
_ => "Custom".to_string(),
};
let is = |b: Option<tr::TraceParams>| b.and_then(|b| serde_json::to_value(&b).ok()).as_ref() == Some(&params_json);
let preset_label = if is(tr::preset(named)) || is(tr::enhanced_preset(named)) { named.to_string() } else { "Custom".to_string() };
let roots = root_ids(s, p)?;
// The image (inside a trace group, or selected directly).
let (target, img) = {
@@ -848,10 +857,10 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
// Gradients: a path the tracer found to be a linear gradient is filled with it.
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
n.appearance = match tp.stroke {
// Traced lines: round ends and corners, as drawn lines have.
// Traced lines: round corners, and ends round or square as the tracer found them.
Some(width) => Appearance {
items: vec![AppearanceItem::Stroke(StrokeLayer {
cap: LineCap::Round,
cap: if tp.square_caps { LineCap::Square } else { LineCap::Round },
join: LineJoin::Round,
..StrokeLayer::new(paint, width * unit)
})],
+13 -5
View File
@@ -259,16 +259,23 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow
// the document.
/// What is still in progress (typing, a drag) is a step of its own: keep it before the history
/// moves, so Undo takes back only that and Redo brings it back, instead of dropping it and undoing
/// the step before. As any new edit it ends the steps to redo, so Redo never swaps the document
/// under an interaction that would then preview on, or cancel to, the document from before (#1719).
/// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the
/// document.
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
let typing = typing_in_progress(s);
s.commit_interaction()?;
if typing {
s.set_tool_option("endTyping", &Value::Bool(true));
}
Ok(())
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
@@ -286,6 +293,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
}
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let st = s.doc_mut()?;
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let label = e.label.clone();
+1
View File
@@ -32,6 +32,7 @@ pub(super) fn import(name: &str, bytes: &[u8]) -> Result<Imported> {
Ok(d) => {
let mut b = Builder { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k: 72.0 / d.dpi, layer: 0 };
b.document(&d)?;
b.doc.limit_depth_noting(&mut b.warnings);
let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::<Vec<_>>();
warnings.extend(b.warnings.iter().map(|w| format!("Affinity: {w}.")));
Ok(Imported { doc: b.doc, warnings, preview_only: false })
+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);
}
}
+13 -7
View File
@@ -139,7 +139,8 @@ fn restore_or_import(
/// (with a warning for each that can't be used).
pub(crate) fn native_file(bytes: &[u8]) -> Result<(Document, vectorcraft_format::FileInfo, Vec<String>)> {
let f = vectorcraft_format::load_file(bytes).map_err(err)?;
let warnings = super::native::install_profiles(f.profiles);
let mut warnings = super::native::install_profiles(f.profiles);
warnings.extend(f.depth_notes);
Ok((f.doc, f.info, warnings))
}
@@ -272,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)
}
@@ -418,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
+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();
+47 -16
View File
@@ -583,6 +583,10 @@ pub(crate) fn is_guide_path(d: &Document, id: NodeId) -> bool {
d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: true, .. }))
}
/// The error of an edit that would nest objects deeper than [`vectorcraft_doc::MAX_DEPTH`].
pub const TOO_DEEP: &str = "objects can't be nested more than 32 deep";
const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catalog rows) names the cap");
/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
fn doc_sane(d: &Document, sel: &Selection) -> bool {
@@ -712,7 +716,7 @@ pub struct Prefs {
pub canvas_color: String,
pub auto_collapse_icon_panels: bool,
/// User Interface › Show Tool Group Labels: the toolbar's group names (Select, Shapes, Draw…);
/// off, a faint dash separates the groups instead (#663).
/// off (as it ships, #1736), the tools run on from group to group with nothing between them.
pub tool_group_labels: bool,
pub open_documents_as_tabs: bool,
pub large_tabs: bool,
@@ -938,7 +942,7 @@ impl Default for Prefs {
ui_brightness: s("mediumDark"),
canvas_color: s("matchUi"),
auto_collapse_icon_panels: false,
tool_group_labels: true,
tool_group_labels: false,
open_documents_as_tabs: true,
large_tabs: false,
ui_scaling: 1.0,
@@ -1225,26 +1229,24 @@ impl Session {
format!("Untitled-{}", self.untitled_counter)
}
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
/// selection boxes and hit testing would use the rough estimate until each text is edited.
/// selection boxes and hit testing would use the rough estimate until each text is edited, and
/// what is fitted to type's bounds (an envelope warping it) would come out differently. That
/// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too,
/// not only in the layers (#1738).
pub(crate) fn refresh_text_bounds(doc: &mut Document) {
// Inline graphics in text take their size from their symbols' art (not saved either).
doc.resolve_inline_art();
let mut texts = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
texts.push(n.id);
}
});
for id in texts {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind)
&& t.cached_bounds.is_none()
{
cmd::typecmd::refresh_bounds(t);
}
let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art));
let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art));
for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) {
refresh_tree_text_bounds(tree);
}
}
/// Add a document and make it active.
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
// Every edit is checked against the document before it, so the first one is within the cap
// (importers and the native reader have seen to theirs already, with a warning).
doc.limit_depth();
Self::refresh_text_bounds(&mut doc);
// Guides from files saved before guides had layers go on a layer.
doc.adopt_guides();
@@ -1262,6 +1264,7 @@ impl Session {
if index >= self.docs.len() {
return false;
}
doc.limit_depth();
Self::refresh_text_bounds(&mut doc);
doc.adopt_guides();
if self.active == Some(index) {
@@ -1476,7 +1479,10 @@ impl Session {
if !st.doc.variables.bindings.is_empty() {
Arc::make_mut(&mut st.doc).prune_variable_bindings();
}
if doc_sane(&st.doc, &st.selection) {
if st.doc.too_deep(Some(&before)) {
// Deeper trees overflow the stack of what walks them.
Err(EngineError::Other(TOO_DEEP.into()))
} else if doc_sane(&st.doc, &st.selection) {
Ok(v)
} else {
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
@@ -1693,6 +1699,31 @@ impl Session {
}
}
/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the
/// nodes on the way to such type are copied.
fn refresh_tree_text_bounds(n: &mut Arc<vectorcraft_doc::Node>) {
fn stale(n: &vectorcraft_doc::Node) -> bool {
matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none())
|| n.extras().mask.as_deref().is_some_and(|m| stale(&m.art))
|| n.children().is_some_and(|ch| ch.iter().any(|c| stale(c)))
}
if !stale(n) {
return;
}
let n = Arc::make_mut(n);
if let NodeKind::Text(t) = &mut n.kind
&& t.cached_bounds.is_none()
{
cmd::typecmd::refresh_bounds(t);
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
refresh_tree_text_bounds(&mut m.art);
}
for c in n.children_mut().into_iter().flatten() {
refresh_tree_text_bounds(c);
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
+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]
+70 -1
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();
@@ -737,6 +770,18 @@ fn image_trace_auto_grouping_groups_what_is_inside() {
(0..120).flat_map(|x| (0..80).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::<Vec<_>>()
};
assert_eq!(shot(&s), shot(&flat));
// In black and white too, where the white inside the ring is a path traced before the ring:
// it stays under the ring in their group (over it, the ring's inner edge would show a seam).
let bw = |grouping: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.make", &json!({"params": {"autoGrouping": grouping}})).unwrap();
let groups = traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).count();
(s, groups)
};
let (grouped, flat) = (bw(true), bw(false));
assert!(grouped.1 > 0 && flat.1 == 0);
assert_eq!(shot(&grouped.0), shot(&flat.0));
// Traced again without the option, it is flat again; expanded, the groups stay.
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true}})).unwrap();
assert_eq!(traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).len(), 3);
@@ -773,6 +818,29 @@ fn image_trace_gradients_fills_with_a_gradient() {
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
}
/// A preset's enhanced version: the preset with Auto Grouping and Shapes, under the preset's name.
#[test]
fn image_trace_enhanced_presets() {
let mut s = session();
image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "enhanced": true})).unwrap();
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
let stored = n.extras().trace.clone().unwrap();
assert_eq!(stored["preset"], "6 Colors");
assert_eq!((stored["params"]["shapes"].as_bool(), stored["params"]["autoGrouping"].as_bool()), (Some(true), Some(true)));
// The disc of the test image is a live ellipse, in a group with nothing (it has nothing inside).
let shapes: Vec<_> = traced_paths(&n).into_iter().filter(|c| matches!(&c.kind, NodeKind::Path { live: Some(_), .. })).collect();
assert_eq!(shapes.len(), 1);
// Listed with each preset; the legacy preset is as it was.
let list = s.execute("imageTrace.presets", &json!({})).unwrap();
let six = list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "6 Colors").unwrap().clone();
assert_eq!((six["params"]["shapes"].as_bool(), six["enhanced"]["shapes"].as_bool()), (Some(false), Some(true)));
assert_eq!(list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "High Fidelity Photo").unwrap()["enhanced"]["shapes"], false);
assert!(s.execute("imageTrace.make", &json!({"preset": "No Such", "enhanced": true})).is_err());
// Flat Logo, a preset of our own, has no enhanced version.
assert!(list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Flat Logo").unwrap()["enhanced"].is_null());
}
#[test]
fn image_trace_release_and_expand() {
let mut s = session();
@@ -874,7 +942,8 @@ fn image_trace_create_strokes_makes_stroked_centre_lines() {
assert!(line.appearance.fill_paint().color().is_none(), "not filled");
let st = line.appearance.items.iter().find_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st) } else { None }).expect("stroked");
assert!((st.width - 6.0).abs() < 1.0, "3 px at 2× is about 6 pt: {}", st.width);
assert_eq!((st.cap, st.join), (LineCap::Round, LineJoin::Round));
// (The line is cut square at its ends, and so is its stroke.)
assert_eq!((st.cap, st.join), (LineCap::Square, LineJoin::Round));
assert_eq!(st.paint, Paint::solid(Color::rgb8(0, 0, 0)));
let sp = &line.path_data().unwrap().subpaths[0];
assert!(!sp.closed);
+32
View File
@@ -288,3 +288,35 @@ fn enveloped_type_keeps_each_runs_colour_on_the_canvas() {
}
assert!(red > 100 && black > 100, "{red} red, {black} black");
}
/// #1738: type warped by an envelope inside a symbol looks the same after a native save and
/// reopen: the type in symbol definitions gets its layout bounds back (they aren't saved), so the
/// envelope is fitted to the same box as before.
#[test]
fn warped_type_in_a_symbol_reopens_unchanged() {
let mut s = session();
s.execute("text.create", &json!({"x": 30, "y": 160, "text": "STUDIO", "size": 72, "color": "#E3AB49"})).unwrap();
s.execute("object.envelope.makeWithWarp", &json!({"style": "wave", "bend": 18})).unwrap();
s.execute("symbol.new", &json!({"name": "Master"})).unwrap();
let render = |s: &Session| vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &Default::default());
let before = render(&s);
let dir = std::env::temp_dir().join(format!("vectorcraft-1738-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("warped.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
let mut o = Session::new();
o.execute("document.open", &json!({"path": path})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let d = &o.doc().unwrap().doc;
let mut texts = 0;
d.symbols[0].art.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
texts += 1;
assert!(t.cached_bounds.is_some(), "the symbol's type has no layout bounds after reopening");
}
});
assert_eq!(texts, 1);
let after = render(&o);
let differ = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| before.pixel(x, y) != after.pixel(x, y)).count();
assert_eq!(differ, 0, "{differ} pixels changed on reopening");
}
+26
View File
@@ -153,3 +153,29 @@ fn undo_and_redo_reuse_the_estimates() {
let delete = st.history.undo.last().unwrap();
assert_eq!(delete.bytes, fresh(delete, &st.doc));
}
/// #1719: Redo while a drag is in progress keeps the drag as a step (which ends the steps to redo,
/// as any new edit does) instead of swapping the document under it: before, the next preview or a
/// cancel went back to the document from before the redo while the history said it was redone.
#[test]
fn redo_during_an_interaction_keeps_the_history_and_the_document_in_step() {
let mut s = session_with_rects(1);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
let moved = first_x(&s);
s.execute("edit.undo", &json!({})).unwrap();
let start = first_x(&s);
// A drag that changed nothing yet is no step: Redo redoes, and a cancel has nothing to undo.
s.begin_interaction("Move").unwrap();
s.execute("edit.redo", &json!({})).unwrap();
s.cancel_interaction().unwrap();
assert_eq!(first_x(&s), moved, "the redone move stays");
s.execute("edit.undo", &json!({})).unwrap();
// A drag that moved the art is the newest step: nothing is left to redo, and nothing is lost.
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 3, "dy": 0})).unwrap();
assert!(s.execute("edit.redo", &json!({})).is_err(), "the drag ended the steps to redo");
assert!(!s.in_interaction());
assert_eq!(first_x(&s), start + 3.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(first_x(&s), start);
}
+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");
+30
View File
@@ -353,3 +353,33 @@ fn a_group_from_a_mesh_holds_its_point_colours() {
let names = r["swatches"].as_array().unwrap();
assert!(names.contains(&json!("R=255 G=0 B=0")) && names.contains(&json!("R=255 G=255 B=255")), "{r}");
}
/// #1735: a global swatch edit reaches opacity-mask art: the mask's colour (and with it what the
/// mask shows) follows the swatch, and a swatch used only in a mask isn't "unused".
#[test]
fn global_swatch_edits_reach_opacity_mask_art() {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "MaskTone", "color": "#ffffff", "global": true}));
let obj = rect(&mut s);
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [obj.0]}));
let m = rect(&mut s);
run(&mut s, "paint.setFill", json!({"swatch": "MaskTone", "ids": [m.0]}));
run(&mut s, "select.set", json!({"ids": [obj.0, m.0]}));
run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false}));
let mask_fill = |s: &Session| {
let n = doc(s).node(obj).unwrap();
n.extras().mask.as_deref().unwrap().art.appearance.fill_paint()
};
assert_eq!(mask_fill(&s).color(), Some(Color::rgb(1.0, 1.0, 1.0)));
assert!(!run(&mut s, "swatch.unused", json!({}))["names"].as_array().unwrap().iter().any(|n| n == "MaskTone"), "used by the mask");
let r = run(&mut s, "swatch.edit", json!({"name": "MaskTone", "color": "#000000"}));
assert_eq!(r["relinked"], 1, "{r}");
let fill = mask_fill(&s);
assert_eq!(fill.color(), Some(Color::rgb(0.0, 0.0, 0.0)));
assert!(matches!(&fill, Paint::Solid { swatch: Some(n), .. } if n == "MaskTone"), "{fill:?}");
// Undo brings the mask's colour back with the swatch.
run(&mut s, "edit.undo", json!({}));
assert_eq!(mask_fill(&s).color(), Some(Color::rgb(1.0, 1.0, 1.0)));
}
+3 -1
View File
@@ -188,7 +188,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,
+42
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.
+872 -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! {
@@ -2440,3 +2457,854 @@ proptest! {
checked("mutated script", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "sample.jsx"}))?;
}
}
// ---------- deep nesting ----------
//
// The tree is walked by recursion, so a file nested deeply enough overflowed the stack: an abort,
// which nothing catches. Every reader caps what it reads, and no document is deeper than
// `MAX_DEPTH`. These run on a thread with the stack of a Windows main thread (1 MB), where an
// overflow ends the test run.
use vectorcraft_doc::MAX_DEPTH;
/// `f` on a thread with a 1 MB stack.
fn on_a_small_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new().stack_size(1 << 20).spawn(f).unwrap().join().unwrap();
}
/// Open `bytes` as the file `name`: an error, or a document within the cap that renders and
/// exports. Returns the document and the import's warnings.
fn open_deep(name: &str, bytes: &[u8]) -> Option<(Document, Vec<String>)> {
let loaded = vectorcraft_engine::cmd::fileio::load(name, bytes).ok()?;
assert!(!loaded.doc.too_deep(None), "{name}: deeper than the cap");
let doc = loaded.doc.clone();
survive(name, move || Some(doc)).unwrap();
Some((loaded.doc, loaded.warnings))
}
fn merged(warnings: &[String]) -> bool {
warnings.iter().any(|w| w.contains("nested more than"))
}
fn paths_in(doc: &Document) -> usize {
let mut paths = 0;
doc.walk(|n| paths += usize::from(n.path_data().is_some()));
paths
}
const SVG_NS: &str = "xmlns=\"http://www.w3.org/2000/svg\"";
/// `n` groups in one another, each named (so it is kept) and with a rectangle beside the next.
fn nested_svg(n: usize) -> String {
let open: String = (0..n).map(|i| format!("<g id=\"g{i}\"><rect width=\"5\" height=\"5\"/>")).collect();
format!("<svg {SVG_NS} width=\"100\" height=\"100\">{open}<rect width=\"10\" height=\"10\"/>{}</svg>", "</g>".repeat(n))
}
#[test]
fn deeply_nested_svg_is_refused_or_merged() {
on_a_small_stack(|| {
// Within what the reader takes: opened, the groups past the cap merged into the last.
let (doc, warnings) = open_deep("deep.svg", nested_svg(40).as_bytes()).expect("40 groups open");
assert!(merged(&warnings), "{warnings:?}");
assert_eq!(paths_in(&doc), 41, "no art is lost");
let (_, warnings) = open_deep("shallow.svg", nested_svg(20).as_bytes()).expect("20 groups open");
assert!(!merged(&warnings), "{warnings:?}");
// As deep as the reader goes (on a stack of its own): opened and merged the same.
let most = vectorcraft_svg::MAX_ELEMENT_DEPTH;
let (doc, warnings) = open_deep("deepest.svg", nested_svg(most - 2).as_bytes()).expect("opens at the reader's cap");
assert!(merged(&warnings), "{warnings:?}");
assert_eq!(paths_in(&doc), most - 1, "no art is lost");
// Past it: an error that says why, whatever the depth.
for n in [most, 2 * most, 200_000] {
let e = vectorcraft_engine::cmd::fileio::load("deep.svg", nested_svg(n).as_bytes()).err().expect("refused").to_string();
assert!(e.contains(&format!("nested more than {most} deep")), "{n}: {e}");
}
// Unnamed groups of one object aren't kept, but usvg walks them all the same.
let plain = format!(
"<svg {SVG_NS} width=\"9\" height=\"9\">{}<rect width=\"1\" height=\"1\"/>{}</svg>",
"<g>".repeat(2 * most),
"</g>".repeat(2 * most)
);
assert!(open_deep("plain.svg", plain.as_bytes()).is_none());
// A chain of `<use>`: shallow XML, a deep tree.
let links: String = (0..3000).map(|i| format!("<g id=\"u{i}\"><use href=\"#u{}\"/></g>", i + 1)).collect();
let chain = format!(
"<svg {SVG_NS} width=\"9\" height=\"9\"><defs>{links}<rect id=\"u3000\" width=\"1\" height=\"1\"/></defs><use href=\"#u0\"/></svg>"
);
assert!(open_deep("chain.svg", chain.as_bytes()).is_none());
// Placed and pasted, as opened.
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
for n in [40, 2 * most] {
let p = json!({"name": "deep.svg", "dataBase64": vectorcraft_format::base64_encode(nested_svg(n).as_bytes())});
let _ = no_panic(s.execute("file.place", &p));
let _ = no_panic(s.execute("clipboard.importSvg", &json!({"svg": nested_svg(n)})));
let _ = no_panic(s.execute("edit.paste", &json!({})));
assert!(!s.doc().unwrap().doc.too_deep(None));
}
});
}
/// A page whose content is `content`, with `resources`.
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()
}
/// A test for each file: it opens as an error or as a document within the cap, on a small stack.
macro_rules! deep_files {
($($test:ident: $name:literal, $bytes:expr;)*) => {$(
#[test]
fn $test() {
on_a_small_stack(|| {
open_deep($name, &$bytes);
});
}
)*};
}
deep_files! {
// PDF: clips inside clips (saved states never restored); arrays in arrays as an operand and as
// an object; dictionaries in dictionaries as a resource and inline; marked content never ended.
pdf_clips_in_clips: "clips.pdf", deep_pdf(&format!("{} 0 0 5 5 re f", "q 0 0 50 50 re W n ".repeat(20_000)), "<< >>");
pdf_arrays_in_an_operand: "arrays.pdf", deep_pdf(&format!("BT {}{} TJ ET 0 0 5 5 re f", "[".repeat(100_000), "]".repeat(100_000)), "<< >>");
pdf_arrays_in_an_object: "boxes.pdf", handmade_pdf("0 0 5 5 re f", "<< >>", &format!("{}0 0 100 100{}", "[".repeat(100_000), "]".repeat(100_000)));
pdf_dictionaries_in_resources: "dicts.pdf", deep_pdf("0 0 5 5 re f", &format!("{}{}", "<< /A ".repeat(50_000), ">> ".repeat(50_000)));
pdf_dictionaries_in_content: "inline.pdf", deep_pdf(&format!("/P {}{} DP 0 0 5 5 re f", "<< /A ".repeat(50_000), ">> ".repeat(50_000)), "<< >>");
pdf_marked_content_never_ended: "marked.pdf", deep_pdf(&format!("{} 0 0 5 5 re f", "/Span << /MCID 0 >> BDC ".repeat(50_000)), "<< >>");
// PostScript: clips inside clips; procedures in procedures; arrays in arrays; a procedure that
// calls itself; dictionaries never ended.
eps_clips_in_clips: "clips.eps", deep_eps(&format!("{} 0 0 5 5 rectfill", "gsave 0 0 50 50 rectclip ".repeat(20_000)));
eps_procedures_in_procedures: "procs.eps", deep_eps(&format!("{}{} 0 0 5 5 rectfill", "{".repeat(100_000), "}".repeat(100_000)));
eps_arrays_in_arrays: "arrays.eps", deep_eps(&format!("{}{} pop 0 0 5 5 rectfill", "[".repeat(100_000), "]".repeat(100_000)));
eps_a_procedure_calling_itself: "runaway.eps", deep_eps("/f { 0 0 5 5 rectfill f } def f");
eps_dictionaries_never_ended: "dicts.eps", deep_eps(&format!("{} 0 0 5 5 rectfill", "1 dict begin ".repeat(100_000)));
}
#[test]
fn deeply_nested_dxf_blocks_are_read_within_the_cap() {
on_a_small_stack(|| {
// Block `B0` inserts `B1`, which inserts `B2`, and so on; the last one holds a line.
let pair = |c: i32, v: &str| (c, v.to_string());
let mut pairs = vec![pair(0, "SECTION"), pair(2, "BLOCKS")];
for i in 0..2_000 {
pairs.extend([pair(0, "BLOCK"), pair(2, &format!("B{i}")), pair(10, "0"), pair(20, "0")]);
if i + 1 < 2_000 {
pairs.extend([pair(0, "INSERT"), pair(2, &format!("B{}", i + 1)), pair(10, "1"), pair(20, "1")]);
} else {
pairs.extend([pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "5"), pair(21, "5")]);
}
pairs.push(pair(0, "ENDBLK"));
}
pairs.extend([pair(0, "ENDSEC"), pair(0, "SECTION"), pair(2, "ENTITIES"), pair(0, "INSERT"), pair(2, "B0"), pair(10, "0"), pair(20, "0")]);
pairs.extend([pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "9"), pair(21, "9"), pair(0, "ENDSEC"), pair(0, "EOF")]);
open_deep("blocks.dxf", dxf_text(&pairs).as_bytes()).expect("opens");
});
}
#[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 {
use vectorcraft_doc::Node;
use vectorcraft_testkit::fixtures::DocBuilder;
let mut b = DocBuilder::new(100.0, 100.0);
let rect =
|b: &mut DocBuilder, x: f64| b.rect_node(vectorcraft_geom::Rect::new(x, 0.0, x + 10.0, 10.0), vectorcraft_color::Color::rgb8(200, 0, 0));
let mut inner = rect(&mut b, 0.0);
for _ in 0..levels {
let beside = rect(&mut b, 20.0);
inner = Node::group(b.alloc(), vec![std::sync::Arc::new(inner), std::sync::Arc::new(beside)]);
inner.opacity = 0.9;
}
b.add(inner);
b.build()
}
#[test]
fn a_document_at_the_cap_saves_opens_and_exports() {
on_a_small_stack(|| {
let doc = nested_document(MAX_DEPTH - 2);
assert!(!doc.too_deep(None));
let back = vectorcraft_format::load_file(&vectorcraft_format::save_file(&doc)).expect("what can be made opens again");
assert!(back.depth_notes.is_empty());
assert_eq!(back.doc.layers, doc.layers);
survive("at the cap", || Some(doc.clone())).unwrap();
// Every format that keeps the nesting reads its own file back within the cap.
let mut s = vectorcraft_engine::Session::new();
s.add_document(doc, None);
for format in ["svg", "pdf", "eps", "ai"] {
let r = s.execute("document.export", &json!({"format": format})).unwrap();
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
open_deep(&format!("cap.{format}"), &bytes).unwrap_or_else(|| panic!("{format} opens"));
}
for cmd in ["select.all", "document.inspect", "document.info", "edit.copy", "edit.paste", "object.ungroup", "edit.undo"] {
no_panic(s.execute(cmd, &json!({}))).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
});
}
#[test]
fn a_native_file_nested_too_deep_is_merged_or_refused() {
on_a_small_stack(|| {
// Saved before the cap by a version that could still open it: opened, the deepest merged.
let old = vectorcraft_format::save_file(&nested_document(MAX_DEPTH + 2));
let (doc, warnings) = open_deep("old.vectorcraft", &old).expect("opens");
assert!(merged(&warnings), "{warnings:?}");
assert_eq!(paths_in(&doc), MAX_DEPTH + 3, "no art is lost");
// Deeper than any version could open, and a hostile file: an error that says why. Written
// by hand: a tree this deep can't be built and saved.
let text = String::from_utf8(vectorcraft_format::save(&nested_document(0), false)).unwrap();
let (open, close) = ("{\"id\":900000,\"kind\":{\"type\":\"group\",\"children\":[", "]}}");
let at = text.find("\"children\":[").expect("the layer's children") + "\"children\":[".len();
for levels in [100, 50_000] {
let deep = format!("{}{}{},{}", &text[..at], open.repeat(levels), close.repeat(levels), &text[at..]);
let e = vectorcraft_engine::cmd::fileio::load("deep.vectorcraft", deep.as_bytes()).err().expect("refused").to_string();
assert!(e.contains("nested too deep"), "{levels}: {e}");
}
});
}
#[test]
fn commands_cannot_nest_deeper_than_the_cap() {
on_a_small_stack(|| {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let mut grouped = 0;
for _ in 0..10_000 {
match no_panic(s.execute("object.group", &json!({}))) {
Ok(_) => grouped += 1,
Err(e) => assert!(e.to_string().contains(vectorcraft_engine::TOO_DEEP), "after {grouped} groups: {e}"),
}
}
// The layer, the groups and the rectangle.
assert_eq!(grouped, MAX_DEPTH - 2);
assert!(!s.doc().unwrap().doc.too_deep(None));
// Other ways down: a clipping mask, a copy pasted, a symbol made of it.
for (cmd, p) in [
("object.clippingMask.make", json!({})),
("edit.copy", json!({})),
("edit.pasteInFront", json!({})),
("symbol.new", json!({"name": "deep"})),
] {
let _ = no_panic(s.execute(cmd, &p));
assert!(!s.doc().unwrap().doc.too_deep(None), "{cmd}");
}
survive("grouped to the cap", || Some((*s.doc().unwrap().doc).clone())).unwrap();
});
}
/// A document whose one object is an instance of the first of `count` symbols: each symbol's art
/// is `levels` groups around an instance of the next symbol, the last one of `last`.
fn symbols_document(count: usize, levels: usize, last: &str) -> Document {
use vectorcraft_doc::{Node, NodeKind, Symbol};
let instance =
|doc: &mut Document, of: String| Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: of, xf: vectorcraft_geom::Affine::IDENTITY });
let mut doc = nested_document(1);
for i in 0..count {
let mut art = instance(&mut doc, if i + 1 < count { format!("s{}", i + 1) } else { last.to_string() });
for _ in 0..levels {
art = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(art)]);
}
doc.symbols.push(Symbol { name: format!("s{i}"), art: std::sync::Arc::new(art) });
}
let first = instance(&mut doc, "s0".into());
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, first).unwrap();
doc
}
/// `doc` saved (as an older version, or a hostile file, could have) and opened again: within the
/// caps (`kept`: as it was), drawn, exported and edited.
fn symbols_survive(what: &str, doc: Document, kept: bool) {
let (back, warnings) = open_deep("symbols.vectorcraft", &vectorcraft_format::save_file(&doc)).expect("opens");
assert_eq!(!warnings.iter().any(|w| w.contains("symbol instances")), kept, "{what}: {warnings:?}");
let mut s = vectorcraft_engine::Session::new();
s.add_document(back, None);
for cmd in [
"select.all",
"document.inspect",
"document.info",
"object.expand",
"symbol.breakLink",
"object.flattenTransparency",
"edit.copy",
"edit.paste",
] {
let _ = no_panic(s.execute(cmd, &json!({})));
assert!(!s.doc().unwrap().doc.too_deep(None), "{what}: {cmd}");
}
for format in ["svg", "pdf", "eps", "png"] {
let _ = no_panic(s.execute("document.export", &json!({"format": format})));
}
}
#[test]
fn a_symbol_holding_itself_opens_without_the_instance() {
on_a_small_stack(|| {
symbols_survive("a symbol holding itself", symbols_document(1, 1, "s0"), false);
symbols_survive("symbols holding one another", symbols_document(3, 2, "s0"), false);
});
}
#[test]
fn symbols_in_symbols_are_drawn_only_so_deep() {
on_a_small_stack(|| {
// As deep as symbols draw: each one's art as deep as art goes.
symbols_survive("symbols at the cap", symbols_document(3, MAX_DEPTH - 2, "none"), true);
symbols_survive("symbols in symbols", symbols_document(40, MAX_DEPTH - 1, "none"), false);
// A symbol can't be made to hold itself, or one that holds it, by editing either.
let mut s = vectorcraft_engine::Session::new();
s.add_document(symbols_document(2, 1, "none"), None);
s.execute("select.all", &json!({})).unwrap();
for (cmd, p) in [
("symbol.new", json!({"name": "s1"})),
("symbol.update", json!({"name": "s1"})),
("symbol.update", json!({"name": "s0"})),
("symbol.replace", json!({"name": "s1"})),
] {
let _ = no_panic(s.execute(cmd, &p));
assert!(!s.doc().unwrap().doc.too_deep(None), "{cmd} {p}");
}
});
}
#[test]
fn a_pattern_painting_its_own_tile_opens_without_the_paint() {
on_a_small_stack(|| {
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, PatternDef};
let painted = |doc: &mut Document, with: &str| {
let fill = Paint::Pattern { pattern: with.into(), xf: vectorcraft_geom::Affine::IDENTITY };
let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 20.0, 20.0));
vectorcraft_doc::Node::path(doc.alloc_id(), square, Appearance::basic(fill, Paint::None, 0.0))
};
// One pattern painting its own tile, and two painting each other's.
for names in [["p", "p"], ["p", "q"]] {
let mut doc = nested_document(1);
let (first, second) = (painted(&mut doc, names[1]), painted(&mut doc, names[0]));
doc.patterns.push(PatternDef::new(names[0], vec![std::sync::Arc::new(first)]));
if names[0] != names[1] {
doc.patterns.push(PatternDef::new(names[1], vec![std::sync::Arc::new(second)]));
}
let on_layer = painted(&mut doc, "p");
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, on_layer).unwrap();
symbols_survive("a pattern painting its own tile", doc, false);
}
});
}
+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();
+7 -2
View File
@@ -4,7 +4,6 @@
//! Every operation counts against a budget and procedures nest only so deep, so a looping or
//! recursing program ends with [`PsError::Limit`] instead of hanging.
use std::cell::RefCell;
use std::rc::Rc;
use vectorcraft_geom::Affine;
@@ -30,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>,
@@ -86,7 +87,7 @@ struct SaveMark {
}
fn new_dict() -> DictRef {
Rc::new(RefCell::new(Dict::new()))
DictRef::new(Dict::new())
}
/// An encoding vector: 256 `/.notdef` names (type is read as Latin-1 whatever the encoding).
@@ -102,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();
@@ -141,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()],
@@ -206,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 ----------
+3 -1
View File
@@ -268,5 +268,7 @@ fn finish_with(out: Out, layers: Vec<Arc<Node>>) -> Imported {
}
}
doc.layers = layers;
Imported { document: doc, warnings: out.warnings, preview: false }
let mut warnings = out.warnings;
doc.limit_depth_noting(&mut warnings);
Imported { document: doc, warnings, preview: false }
}
+1
View File
@@ -993,6 +993,7 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr
}
None => unmark(&mut doc.layers, true, &mut vec![]),
}
doc.limit_depth_noting(&mut notes);
Ok((doc, notes))
}
+175 -7
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;
@@ -12,7 +12,163 @@ use super::graphics::GState;
pub(crate) type Dict = HashMap<Key, Obj>;
/// A shared, mutable dictionary.
pub(crate) type DictRef = Rc<RefCell<Dict>>;
#[derive(Clone, Debug)]
pub(crate) struct DictRef(Rc<RefCell<Dict>>);
impl DictRef {
pub fn new(d: Dict) -> Self {
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 {
&self.0
}
}
impl Drop for DictRef {
fn drop(&mut self) {
if Rc::strong_count(&self.0) == 1
&& let Ok(mut d) = self.0.try_borrow_mut()
{
drop_held(d.drain().map(|(_, v)| v));
}
}
}
thread_local! {
/// What the composite objects being dropped held, waiting for their turn (while any is).
static HELD: RefCell<Option<Vec<Obj>>> = const { RefCell::new(None) };
}
/// Drop what a composite object held, now that nothing else refers to it, without recursion: a
/// program nests arrays and dictionaries as deep as it likes (`[ exch ]` in a loop), and dropping
/// them one inside the other overflows the stack. What `held` holds in its turn waits in [`HELD`]
/// for the outermost drop, which works through it.
fn drop_held(held: impl Iterator<Item = Obj>) {
let holds = |o: &Obj| matches!(o, Obj::Array { .. } | Obj::Dict(_) | Obj::File { .. } | Obj::GState(_));
// (Without the list, when the thread is ending: dropped as they come.)
let outermost = HELD.try_with(|h| {
let Ok(mut h) = h.try_borrow_mut() else { return false };
let outermost = h.is_none();
h.get_or_insert_with(Vec::new).extend(held.filter(holds));
outermost
});
if !matches!(outermost, Ok(true)) {
return;
}
// Each drop here puts what it held on the list instead of dropping it.
while let Ok(Some(next)) = HELD.try_with(|h| h.try_borrow_mut().ok().and_then(|mut h| h.as_mut().and_then(Vec::pop))) {
drop(next);
}
let _ = HELD.try_with(|h| h.try_borrow_mut().map(|mut h| *h = None)); // Nothing to do when the thread is ending.
}
/// What [`Shared`] values may be: bytes, or objects (which may hold more of themselves).
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 {}
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.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -72,7 +228,7 @@ impl Obj {
}
pub fn dict(d: Dict) -> Self {
Self::Dict(Rc::new(RefCell::new(d)))
Self::Dict(DictRef::new(d))
}
/// The number this is (integers and reals).
@@ -135,15 +291,27 @@ impl Obj {
/// The values of a string or an array: a run of storage that other strings or arrays may share
/// (`getinterval` makes a new object over part of the same values, as PostScript does).
#[derive(Clone, Debug)]
pub(crate) struct Shared<T> {
pub(crate) struct Shared<T: Held> {
buf: Rc<RefCell<Vec<T>>>,
at: usize,
len: usize,
}
impl<T: Clone> Shared<T> {
impl<T: Held> Drop for Shared<T> {
fn drop(&mut self) {
if Rc::strong_count(&self.buf) == 1
&& let Ok(mut values) = self.buf.try_borrow_mut()
{
T::release(std::mem::take(&mut *values));
}
}
}
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 {
@@ -198,7 +366,7 @@ impl<T: Clone> Shared<T> {
}
/// Hashes what [`Shared::same`] compares: the storage and the run of it.
impl<T> Hash for Shared<T> {
impl<T: Held> Hash for Shared<T> {
fn hash<H: Hasher>(&self, state: &mut H) {
Rc::as_ptr(&self.buf).hash(state);
(self.at, self.len).hash(state);
+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()
+2 -2
View File
@@ -389,7 +389,7 @@ impl Interp<'_> {
d.insert(Key::name("FontMatrix"), matrix_obj(Affine::scale(0.001)));
d.insert(Key::name("FontBBox"), Obj::array(vec![Obj::Int(0), Obj::Int(-250), Obj::Int(1000), Obj::Int(1000)]));
d.insert(Key::name("Encoding"), Obj::array(vec![Obj::name(".notdef"); 256]));
let font = Rc::new(std::cell::RefCell::new(d));
let font = DictRef::new(d);
self.fonts.borrow_mut().insert(key, Obj::Dict(font.clone()));
Ok(font)
}
@@ -489,7 +489,7 @@ fn transformed(font: &DictRef, m: Affine) -> DictRef {
let mut d = font.borrow().clone();
let fm = d.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
d.insert(Key::name("FontMatrix"), matrix_obj(m * fm));
Rc::new(std::cell::RefCell::new(d))
DictRef::new(d)
}
/// The character a glyph name stands for (`A`, `space`, `uni20AC`), else a bullet.
+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);
}
+11 -2
View File
@@ -482,6 +482,9 @@ pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
#[derive(Debug)]
pub struct NativeFile {
pub doc: Document,
/// What was done to bring the document within the nesting caps
/// ([`Document::limit_depth_noting`]: a file saved before them, or a damaged one).
pub depth_notes: Vec<String>,
pub info: FileInfo,
/// ICC files by profile name (saved with Embed ICC Profiles; undecodable ones are left out).
pub profiles: BTreeMap<String, Vec<u8>>,
@@ -511,7 +514,11 @@ pub fn load_info(bytes: &[u8]) -> Result<(Document, FileInfo), FormatError> {
/// [`load`], with everything else the file carries.
pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
let file = unpack::Unpacked::new(bytes)?;
let f: File = serde_json::from_slice(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
// The JSON reader stops at 128 levels (a document's objects use three each).
let f: File = serde_json::from_slice(file.json()).map_err(|e| {
let why = if e.to_string().contains("recursion limit") { format!("its objects are nested too deep ({e})") } else { e.to_string() };
FormatError::NotVectorcraft(why)
})?;
if f.format != EXTENSION && f.format != LEGACY_EXTENSION {
return Err(FormatError::NotVectorcraft(format!("format is `{}`", f.format)));
}
@@ -545,8 +552,10 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
// Untrusted: capped, named and pointing at objects the file has.
doc.tidy_saved_selections();
doc.tidy_graph_designs();
let mut depth_notes = vec![];
doc.limit_depth_noting(&mut depth_notes);
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect();
Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
}
/// The members of a native file besides its document (which is skipped, not decoded).
+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);
}
}
+3 -1
View File
@@ -102,7 +102,9 @@ pub struct Imported {
/// Read an EMF or WMF file (with or without a placeable header) into a document whose one
/// artboard is the picture's frame.
pub fn import(bytes: &[u8]) -> Result<Imported, String> {
import::import(bytes)
let mut i = import::import(bytes)?;
i.document.limit_depth_noting(&mut i.warnings);
Ok(i)
}
#[cfg(test)]
+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 }
krilla = { 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
@@ -1336,7 +1336,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));
+113 -64
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,6 +147,13 @@ 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())?;
let mut ocgs = Ocgs::read(&original);
@@ -174,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)
@@ -417,6 +427,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
}
}
}
doc.limit_depth_noting(&mut warnings);
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text })
}
@@ -1259,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;
}
@@ -1352,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))
}
@@ -1681,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) {
@@ -1704,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;
@@ -1726,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 {
@@ -1750,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() });
@@ -1761,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
@@ -1796,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),
}
}
@@ -1831,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;
@@ -1972,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};
+134 -1
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,12 +37,135 @@ pub fn is_postscript(bytes: &[u8]) -> bool {
bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6])
}
/// 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;
/// 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()
}
/// 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'{' => depth += 1,
b'}' => depth = depth.saturating_sub(1),
_ => {}
}
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 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);
read(password)
@@ -55,6 +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:?}")),
})
// 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
@@ -734,3 +734,46 @@ fn opacity_mask_exports_as_luminosity_soft_mask() {
assert!(text.contains("/SMask"), "soft mask in an ExtGState");
assert!(text.contains("/Luminosity"));
}
/// 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
+81 -18
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| {
@@ -869,10 +904,10 @@ impl Renderer {
// A symbol's art can change while the instance doesn't: no cached shadows.
let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0);
let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) };
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_node(c, fr, &art, true));
self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art));
ctx.set_transform(Affine::IDENTITY);
}
_ => self.draw_node(ctx, f, &art, true),
_ => self.draw_fx_art(ctx, f, a, &art),
}
self.made_again = outer;
}
@@ -939,6 +974,19 @@ impl Renderer {
art
}
/// Draw `art`, the art [`Self::fx_art`] made for `a`. Type art that `fx_arts` keeps for `a` is
/// drawn as `fx_owner`, so [`Self::draw_text`] takes its glyph geometry from `texts`.
fn draw_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, art: &Arc<Node>) {
let kept =
matches!(art.kind, NodeKind::Text(_)) && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, art));
if !kept {
return self.draw_node(ctx, f, art, true);
}
let outer = self.fx_owner.replace(art.clone());
self.draw_node(ctx, f, art, true);
self.fx_owner = outer;
}
/// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on
/// everything ([`effects::adjust`]). Embedded images become recoloured copies, cached with the
/// decoded images under a key naming the adjustment.
@@ -1352,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);
@@ -1373,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();
}
@@ -1469,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();
+24 -8
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);
@@ -1602,7 +1606,14 @@ impl Renderer {
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
let t = &*f.doc.inline_resolved(t);
let g = text_geom_snapped(t, f.text_snap(t));
// Type drawn as `fx_owner` (the node `draw_arc` is drawing, or type art that `fx_arts`
// keeps, see `draw_fx_art`) takes its glyph geometry from `texts`, as on the fast path,
// except in frames that snap type to pixels.
let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned();
let g = match (f.text_snap(t), owner) {
(None, Some(a)) => self.text_geom_of(&a, t),
(snap, _) => Arc::new(text_geom_snapped(t, snap)),
};
self.draw_text_geom(ctx, f, n, t, &g);
}
@@ -1670,7 +1681,12 @@ impl Renderer {
}
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).
/// Text `t` that is not the node's own, such as the copy whose inline graphics
/// `Document::inline_resolved` sized again, is laid out without the cache.
fn text_geom_of(&mut self, a: &Arc<Node>, t: &TextObject) -> Arc<TextGeom> {
if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) {
return Arc::new(text_geom(t));
}
let fonts = vectorcraft_text::FontDb::global().generation();
if fonts != self.text_fonts {
self.texts.clear();
+48 -4
View File
@@ -6,14 +6,16 @@
//! 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), while what
//! the document holds of those is what it held then ([`Deps`]).
//!
//! 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_doc::pattern::{Overlap, PatternDef, TileType};
use vectorcraft_doc::{Document, Node};
@@ -32,8 +34,46 @@ 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, 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>>>,
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(),
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()
&& 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>,
params: (Rect, TileType, Overlap),
sx: f64,
sy: f64,
@@ -163,7 +203,10 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
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) && 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 +219,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, 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.
+31
View File
@@ -83,6 +83,37 @@ fn type_with_raster_effects_redraws_when_its_font_becomes_available() {
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
/// available: the glyph geometry kept for it is laid out again.
#[test]
fn translucent_blended_and_shadowed_type_redraws_when_its_font_becomes_available() {
const FAMILY: &str = "Blended Serif4";
let mut d = Document::new(300.0, 150.0);
let l = d.layers[0].id;
for i in 0..3 {
let style = CharStyle { size: 36.0, font_family: FAMILY.into(), ..Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 40.0 + 45.0 * i as f64), "Hamburg", style))));
match i {
0 => n.opacity = 0.8,
1 => n.blend = vectorcraft_color::BlendMode::Multiply,
_ => n.appearance.effects.push(vectorcraft_doc::Effect {
id: "stylize.dropShadow".into(),
params: serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}),
visible: true,
}),
}
d.insert(Some(l), i, n).unwrap();
}
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_eq!(r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, before, "drawn again from the kept glyph geometry");
assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, after, "drawn in the font that arrived");
assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it");
}
/// Type with a geometry effect in a symbol's art redraws in a font that becomes available: the art
/// kept for the instance is placed again when the fonts change.
#[test]
+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;
}
+302
View File
@@ -182,6 +182,122 @@ fn swept_type_entries_give_back_their_bytes() {
assert_eq!((r.texts.len(), r.texts.bytes()), (0, 0));
}
/// [`text_doc`] of three type objects reading `word`: one at 80% opacity, one in Multiply and one
/// with a drop shadow.
fn faded_text_doc(word: &str) -> Document {
let mut d = text_doc(3, word);
let ids: Vec<NodeId> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect();
d.node_mut(ids[0]).unwrap().opacity = 0.8;
d.node_mut(ids[1]).unwrap().blend = vectorcraft_color::BlendMode::Multiply;
d.node_mut(ids[2]).unwrap().appearance.effects.push(drop_shadow());
d
}
/// The glyph geometry `r` keeps for each type object of `d`: under its node, or under the art
/// drawn for it when it has effects.
fn kept_glyphs(r: &mut Renderer, d: &Document) -> Vec<Option<Arc<TextGeom>>> {
let stamp = r.stamp;
text_keys(d)
.into_iter()
.map(|k| {
let k = r.fx_arts.get(&k, stamp).map_or(k, |e| Arc::as_ptr(&e.1) as usize);
r.texts.get(&k, stamp).map(|(_, g)| g.clone())
})
.collect()
}
/// `f`'s result from a run during which the fonts did not change: other tests add fonts, and the
/// renderer lays all type out again when the fonts change.
fn fonts_unchanged<T>(mut f: impl FnMut() -> T) -> T {
loop {
let fonts = vectorcraft_text::FontDb::global().generation();
let t = f();
if vectorcraft_text::FontDb::global().generation() == fonts {
return t;
}
}
}
/// Type at 80% opacity, in Multiply and with a drop shadow keeps its glyph geometry between
/// frames, as opaque type does: the second frame draws the geometry the first laid out. Frames
/// that snap type to pixels keep none, and neither does type art that the effect-art cache has no
/// room for.
#[test]
fn translucent_blended_and_shadowed_type_keeps_its_glyph_geometry_between_frames() {
let d = faded_text_doc("Kept");
let opts = RenderOptions::default();
let (first, kept, entries, second, again) = fonts_unchanged(|| {
let mut r = Renderer::new();
let first = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels;
let (kept, entries) = (kept_glyphs(&mut r, &d), r.texts.len());
let second = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels;
(first, kept, entries, second, kept_glyphs(&mut r, &d))
});
assert!(kept.iter().all(Option::is_some) && entries == 3, "{entries} entries");
assert!(second == first);
let same = kept.iter().zip(&again).all(|pair| matches!(pair, (Some(a), Some(b)) if Arc::ptr_eq(a, b)));
assert!(same, "the second frame draws the geometry the first laid out");
// Type anti-aliasing snaps the glyphs to pixels.
let mut r = Renderer::new();
r.render(&d, 400, 400, Affine::IDENTITY, &RenderOptions { anti_alias: AntiAlias::Type, ..Default::default() });
assert!(r.texts.is_empty());
// With no room for effect art, the shadowed type's art is made in every frame.
let frames = fonts_unchanged(|| {
let mut r = Renderer::new();
r.fx_arts = FrameCache::new(0, 0);
(0..2).map(|_| (r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels == first, r.texts.len())).collect::<Vec<_>>()
});
assert_eq!(frames, [(true, 2), (true, 2)]);
}
/// Editing the text of type at 80% opacity, in Multiply or with a drop shadow lays it out again:
/// the next frame draws what a new renderer draws.
#[test]
fn edited_translucent_blended_and_shadowed_type_draws_as_new() {
for i in 0..3 {
let mut d = faded_text_doc("Kept");
let id = d.layers[0].children().unwrap()[i].id;
let opts = RenderOptions::default();
draws_as_new(&mut d, &opts, &opts, |d| {
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.runs[0].text = "Edit".into();
}
});
}
}
/// Type showing an inline graphic, at 80% opacity, in Multiply, with a drop shadow and opaque:
/// when the symbol's art changes size and the document's inline graphics are not resolved again,
/// the next frame lays the type out with the new size, as a new renderer does.
#[test]
fn type_with_an_inline_graphic_draws_its_symbols_new_size() {
use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun};
for i in 0..4 {
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 style = CharStyle { size: 24.0, ..Default::default() };
let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "A", style.clone());
t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone()));
t.runs.push(TextRun::new("B", style));
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
match i {
0 => n.opacity = 0.8,
1 => n.blend = vectorcraft_color::BlendMode::Multiply,
2 => n.appearance.effects.push(drop_shadow()),
_ => {}
}
let mut d = with_art(d, vec![n]);
d.resolve_inline_art();
// `S` becomes a wide red bar: the inline graphic grows, and the type after it moves.
edit_draws_as_new(d, |d| {
let bar = vectorcraft_geom::shapes::rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0));
let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols[0].art = Arc::new(Node::path(d.alloc_id(), bar, red));
});
}
}
/// A symbol's art inside the 20 × 20 square around the origin: a group of a square filled blue
/// with a 2 pt black stroke and a black line.
fn symbol_art(d: &mut Document) -> Node {
@@ -824,6 +940,49 @@ 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, or another pattern it is painted 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);
}
/// 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
@@ -977,6 +1136,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)),
}
}
+34 -14
View File
@@ -18,10 +18,13 @@ use crate::{ImportOptions, SvgError, fnv1a};
use css::XNode;
use text::TextSlots;
pub use depth::MAX_ELEMENT_DEPTH;
pub(crate) use depth::xml_too_deep;
pub(crate) use fx::BLEND;
mod css;
mod css_transform;
mod depth;
mod files;
mod fx;
mod spread;
@@ -29,11 +32,30 @@ mod symbol_units;
mod text;
mod vector_effect;
/// The XML of `svg`.
fn parse(svg: &str) -> Result<roxmltree::Document<'_>, SvgError> {
roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() })
.map_err(|e| SvgError::Parse(e.to_string()))
}
pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
let (svg, found) = prepass(svg, opts);
// Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by
// recursion.
let too_deep = || SvgError::TooDeep(depth::MAX_ELEMENT_DEPTH);
if depth::xml_too_deep(svg) {
return Err(too_deep());
}
let xml = parse(svg)?;
if depth::too_deep(&xml) {
return Err(too_deep());
}
let (svg, found) = prepass(svg, &xml, opts);
// The prepass's edits are in the text: the XML is read again only when it made any.
let xml = match &svg {
Cow::Borrowed(_) => xml,
Cow::Owned(edited) => parse(edited)?,
};
let svg = svg.as_ref();
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() })
.map_err(|e| SvgError::Parse(e.to_string()))?;
let units = RootUnits::of(xml.root_element());
let root_name = root_layer_name(xml.root_element());
let mut warnings = found.warnings;
@@ -165,6 +187,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
promote_sublayers(Arc::make_mut(l), &im.sublayer_groups);
}
}
im.doc.limit_depth_noting(&mut im.warnings);
Ok((im.doc, im.warnings))
}
@@ -554,7 +577,7 @@ impl Edits {
/// * linked files are read ([`files`]);
/// * percentage lengths in a `<symbol>` with a `viewBox` become numbers ([`symbol_units`]);
/// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]).
fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
let mut found = Found::default();
// (`:` for prefixed elements such as `<svg:image>`.)
let css_transform = has_css_transform(svg);
@@ -565,9 +588,6 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
{
return (svg.into(), found);
}
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
return (svg.into(), found);
};
let mut edits = Edits::default();
let elements = || xml.descendants().filter(|n| n.is_element());
for a in elements().filter(|n| n.tag_name().name() == "a") {
@@ -592,24 +612,24 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
}
let (display, non_scaling) = (svg.contains("display"), svg.contains(vector_effect::NON_SCALING));
if display || non_scaling || not {
let css = css::Styles::new(&xml);
let css = css::Styles::new(xml);
// First: an undisplayed object shown for usvg is shown after these.
if not && css.has_not_rules() {
not_rules(svg, &xml, &css, &mut edits);
not_rules(svg, xml, &css, &mut edits);
}
if display {
hidden_objects(svg, &xml, &css, &mut edits, &mut found.hidden);
hidden_objects(svg, xml, &css, &mut edits, &mut found.hidden);
}
if non_scaling {
vector_effect::find(svg, &xml, &css, &mut edits, &mut found.non_scaling, &mut found.warnings);
vector_effect::find(svg, xml, &css, &mut edits, &mut found.non_scaling, &mut found.warnings);
}
}
found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings);
found.files = files::Files::read(svg, xml, opts, &mut edits, &mut found.warnings);
if !symbols.is_empty() && svg.contains('%') {
edits.list.extend(symbol_units::rewrites(&xml));
edits.list.extend(symbol_units::rewrites(xml));
}
if css_transform {
edits.list.extend(css_transform::rewrites(svg, &xml));
edits.list.extend(css_transform::rewrites(svg, xml));
}
(edits.apply(svg), found)
}
+55 -8
View File
@@ -17,7 +17,7 @@ pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The
/// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557).
fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
pub(super) fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
s.split(';')
.filter_map(|d| {
let (k, v) = d.split_once(':')?;
@@ -214,7 +214,13 @@ impl Selector {
}
fn matches(&self, n: XNode) -> bool {
fn go(parts: &[(Compound, bool)], n: XNode) -> bool {
// The result of matching a selector suffix from a node depends only on the suffix and
// the node, so memoize it: without this, every descendant step retries every matching
// ancestor and a selector whose leftmost compound never matches makes the search
// enumerate ancestor combinations (#1703). A suffix of `parts` is identified by its
// length, so (suffix length, node id) is a complete key and the visited states are
// bounded by parts × ancestors.
fn go(parts: &[(Compound, bool)], n: XNode, memo: &mut HashMap<(usize, roxmltree::NodeId), bool>) -> bool {
let Some(((c, child), rest)) = parts.split_first() else { return true };
if !c.matches(n) {
return false;
@@ -222,13 +228,19 @@ impl Selector {
if rest.is_empty() {
return true;
}
if *child {
n.parent_element().is_some_and(|p| go(rest, p))
} else {
n.ancestors().skip(1).filter(|a| a.is_element()).any(|a| go(rest, a))
let key = (rest.len(), n.id());
if let Some(&hit) = memo.get(&key) {
return hit;
}
let hit = if *child {
n.parent_element().is_some_and(|p| go(rest, p, memo))
} else {
n.ancestors().skip(1).filter(|a| a.is_element()).any(|a| go(rest, a, memo))
};
memo.insert(key, hit);
hit
}
go(&self.parts, n)
go(&self.parts, n, &mut HashMap::new())
}
}
@@ -371,8 +383,13 @@ impl Styles {
Self { rules, index, cache: RefCell::default() }
}
/// Do the document's style sheets have any rules?
pub(super) fn has_rules(&self) -> bool {
!self.rules.is_empty()
}
/// Properties set on `n` by rules and its `style` attribute, after the cascade.
fn declared(&self, n: XNode) -> Rc<HashMap<String, String>> {
pub(super) fn declared(&self, n: XNode) -> Rc<HashMap<String, String>> {
if let Some(m) = self.cache.borrow().get(&n.id()) {
return m.clone();
}
@@ -484,6 +501,36 @@ mod tests {
);
}
#[test]
fn descendant_matching_does_not_backtrack_combinatorially() {
// Many nested groups share class `a` and the first rule stacks eight descendant
// `.a` compounds under a leftmost class that matches nothing: an unmemoized
// matcher enumerates ancestor combinations for it and effectively never
// finishes (#1703). With suffix states memoized this returns at once, and the
// matching descendant and mixed child/descendant rules still apply.
let depth = 48;
let mut svg = String::from(
r#"<svg xmlns="http://www.w3.org/2000/svg"><style>
.missing .a .a .a .a .a .a .a .a text { fill: #111 }
.a .a .a text { fill: #222 }
.a > .a text { stroke: blue }
</style>"#,
);
for _ in 0..depth {
svg.push_str(r#"<g class="a">"#);
}
svg.push_str(r#"<text id="t">x</text>"#);
for _ in 0..depth {
svg.push_str("</g>");
}
svg.push_str("</svg>");
with_doc(&svg, |doc, st| {
let t = by_id(doc, "t");
assert_eq!(st.own(t, "fill").as_deref(), Some("#222"), "descendant rule matches, the .missing one does not");
assert_eq!(st.own(t, "stroke").as_deref(), Some("blue"), "mixed child/descendant rule matches");
});
}
#[test]
fn presentation_attributes_lose_to_rules() {
with_doc(
+278
View File
@@ -0,0 +1,278 @@
//! How deep an SVG's elements nest, found in the XML before usvg runs: usvg converts the tree by
//! recursion, a few stack frames an element, so a file nested deeply enough overflows the stack
//! (an abort, not an error). Such a file is refused: its nesting can't be merged before usvg has
//! read it, and usvg is what can't read it.
//!
//! What counts is the tree usvg walks, not the XML alone: a `<use>` holds a copy of what it
//! refers to, and a clip path, mask, pattern, marker or filter is converted where it is used
//! (`clip-path: url(#a)` on an element of a clip path makes a chain as deep as there are clip
//! paths). So each element's depth is the deepest of what it holds and what it refers to.
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use usvg::roxmltree;
use super::css::{Styles, XNode, parse_decls};
/// The deepest an SVG's elements may nest, references followed. Far beyond what drawing apps
/// write, and deeper than a document's objects may nest ([`vectorcraft_doc::MAX_DEPTH`]: wrappers
/// 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
/// 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.
const INHERITED: [&str; 6] = ["fill", "stroke", "marker", "marker-start", "marker-mid", "marker-end"];
/// The ids of the `url(#id)` references in a property value.
fn urls(value: &str) -> impl Iterator<Item = &str> {
value.split("url(").skip(1).filter_map(|rest| {
let id = rest.trim_start().trim_start_matches(['"', '\'']).strip_prefix('#')?;
id.split([')', '"', '\'']).next().map(str::trim).filter(|id| !id.is_empty())
})
}
/// The elements `n` refers to, each with whether `n`'s descendants use it too.
fn references<'a, 'i>(n: XNode<'a, 'i>, ids: &HashMap<&str, XNode<'a, 'i>>, css: &Styles, out: &mut Vec<(XNode<'a, 'i>, bool)>) {
let add = |out: &mut Vec<(XNode<'a, 'i>, bool)>, name: &str, value: &str| {
let inherited = INHERITED.contains(&name);
out.extend(urls(value).filter_map(|id| ids.get(id)).map(|t| (*t, inherited)));
};
for a in n.attributes() {
match a.name() {
"href" => out.extend(a.value().trim().strip_prefix('#').and_then(|id| ids.get(id)).map(|t| (*t, false))),
// Read with the style sheets' rules below.
"style" if css.has_rules() => {}
"style" => parse_decls(a.value()).iter().for_each(|(k, v, _)| add(out, k, v)),
name => add(out, name, a.value()),
}
}
if css.has_rules() {
css.declared(n).iter().for_each(|(k, v)| add(out, k, v));
}
}
/// Do the elements of the XML text `svg` nest deeper than [`MAX_ELEMENT_DEPTH`]? Read from the
/// text, before any XML reader: roxmltree (ours and usvg's) reads elements by recursion too. Tags
/// are counted as the text has them; markup in the values of a document type's entities counts
/// ten times (how deep entities may refer to one another), on top of the deepest element.
pub(crate) fn xml_too_deep(svg: &str) -> bool {
let b = svg.as_bytes();
let find =
|from: usize, what: &[u8]| b.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at + what.len());
// The end of the tag starting at `from` (past its `>`), and whether it closes itself.
let tag_end = |from: usize| {
let mut quote = None;
for (i, c) in b.iter().enumerate().skip(from) {
match (quote, c) {
(None, b'"' | b'\'') => quote = Some(*c),
(Some(q), c) if q == *c => quote = None,
(None, b'>') => return (i + 1, b.get(i.wrapping_sub(1)) == Some(&b'/')),
_ => {}
}
}
(b.len(), false)
};
// (now, deepest) of the elements, and of the markup inside the document type.
let (mut elements, mut entities) = ((0usize, 0usize), (0usize, 0usize));
let mut doctype_end = 0;
let mut i = 0;
while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|c| *c == b'<')).map(|at| i + at) {
let rest = b.get(at..).unwrap_or_default();
let depth = if at < doctype_end { &mut entities } else { &mut elements };
i = if rest.starts_with(b"<!--") {
find(at, b"-->").unwrap_or(b.len())
} else if rest.starts_with(b"<![CDATA[") {
find(at, b"]]>").unwrap_or(b.len())
} else if rest.starts_with(b"<?") {
find(at, b"?>").unwrap_or(b.len())
} else if rest.starts_with(b"<!DOCTYPE") {
// Up to the `>` that ends it: after its internal subset (`[` … `]`) when it has one.
let plain = tag_end(at).0;
let subset = b.get(at..plain).is_some_and(|head| head.contains(&b'['));
doctype_end = if subset { find(at, b"]>").unwrap_or(b.len()) } else { plain };
at + 2
} else if rest.starts_with(b"<!") {
// A declaration of the document type: the markup in its value is counted as it comes.
at + 2
} else if rest.starts_with(b"</") {
depth.0 = depth.0.saturating_sub(1);
tag_end(at).0
} else if at < doctype_end {
// In an entity's value, itself in quotes: up to the tag's `>`.
let end = find(at, b">").unwrap_or(b.len());
depth.1 = depth.1.max(depth.0 + 1);
depth.0 += usize::from(b.get(end.wrapping_sub(2)) != Some(&b'/'));
end
} else {
let (end, empty) = tag_end(at);
depth.1 = depth.1.max(depth.0 + 1);
depth.0 += usize::from(!empty);
end
};
if elements.1 + 10 * entities.1 > MAX_ELEMENT_DEPTH {
return true;
}
}
false
}
/// Does anything in `xml` nest deeper than [`MAX_ELEMENT_DEPTH`], references followed? Without
/// recursion.
pub(super) fn too_deep(xml: &roxmltree::Document) -> bool {
let elements = || xml.descendants().filter(|n| n.is_element());
let ids: HashMap<&str, XNode> = elements().filter_map(|n| Some((n.attribute("id")?, n))).collect();
let css = Styles::new(xml);
// How deep each element's own tree goes, itself included; 0 while it is being worked out: a
// reference back to it then is a loop, which usvg leaves out.
let mut heights: HashMap<roxmltree::NodeId, usize> = HashMap::new();
enum Step<'a, 'i> {
Enter(XNode<'a, 'i>),
Leave(XNode<'a, 'i>, Vec<(XNode<'a, 'i>, bool)>),
}
let mut stack = vec![Step::Enter(xml.root_element())];
while let Some(step) = stack.pop() {
match step {
Step::Enter(n) => {
match heights.entry(n.id()) {
Entry::Occupied(_) => continue,
Entry::Vacant(v) => v.insert(0),
};
let mut refs = vec![];
references(n, &ids, &css, &mut refs);
let targets: Vec<XNode> = refs.iter().map(|(t, _)| *t).collect();
stack.push(Step::Leave(n, refs));
stack.extend(n.children().filter(|c| c.is_element()).chain(targets).map(Step::Enter));
}
Step::Leave(n, refs) => {
let of = |n: XNode| heights.get(&n.id()).copied().unwrap_or(0);
let held = n.children().filter(|c| c.is_element()).map(of).max().unwrap_or(0);
let used = |inherited: bool| refs.iter().filter(|(_, i)| *i == inherited).map(|(t, _)| of(*t)).max().unwrap_or(0);
let height = 1 + held.max(used(false)) + used(true);
if height > MAX_ELEMENT_DEPTH {
return true;
}
heights.insert(n.id(), height);
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn deep(svg: &str) -> bool {
too_deep(&roxmltree::Document::parse(svg).unwrap())
}
fn svg(body: &str) -> String {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="10" height="10">{body}</svg>"#)
}
/// `n` groups in one another around a rectangle.
fn groups(n: usize) -> String {
format!("{}<rect width='1' height='1'/>{}", "<g>".repeat(n), "</g>".repeat(n))
}
#[test]
fn elements_nest_up_to_the_cap() {
// The root, the groups and the rectangle.
let (at, past) = (svg(&groups(MAX_ELEMENT_DEPTH - 2)), svg(&groups(MAX_ELEMENT_DEPTH - 1)));
assert!(!deep(&at) && !xml_too_deep(&at));
assert!(deep(&past) && xml_too_deep(&past));
assert!(xml_too_deep(&svg(&groups(200_000))), "any depth is measured");
}
#[test]
fn the_text_is_measured_as_xml_reads_it() {
let many = r#"<g a='>' b="<g>"><rect/></g>"#.repeat(500);
assert!(!xml_too_deep(&svg(&many)), "side by side, with `>` and tags in attribute values");
let hidden = format!("<!-- {0} --><![CDATA[{0}]]><?pi {0}?>", "<g>".repeat(500));
assert!(!xml_too_deep(&svg(&hidden)), "comments, character data and instructions hold no elements");
assert!(xml_too_deep(&format!("<svg>{}", "<g>".repeat(MAX_ELEMENT_DEPTH))), "never closed");
assert!(!xml_too_deep(&format!("{}<svg/>", "</g>".repeat(2 * MAX_ELEMENT_DEPTH))), "ends without beginnings");
// Entities: namespaces as drawing apps declare them, and markup that would be read ten deep.
let names = r#"<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [ <!ENTITY ns_svg "http://www.w3.org/2000/svg"> ]>"#;
assert!(!xml_too_deep(&format!("{names}{}", svg(&groups(30)))));
let tenth = MAX_ELEMENT_DEPTH / 10 + 1;
let bomb = format!("<!DOCTYPE svg [ <!ENTITY a \"{}&a;{}\"> ]>", "<g>".repeat(tenth), "</g>".repeat(tenth));
assert!(xml_too_deep(&format!("{bomb}<svg>&a;</svg>")));
assert!(!xml_too_deep(""));
}
#[test]
fn a_chain_of_uses_is_as_deep_as_what_it_copies() {
let chain = |n: usize| {
let defs: String = (0..n).map(|i| format!("<g id='g{i}'><use xlink:href='#g{}'/></g>", i + 1)).collect();
svg(&format!("<defs>{defs}<rect id='g{n}' width='1' height='1'/></defs><use href='#g0'/>"))
};
assert!(!deep(&chain(10)));
assert!(deep(&chain(MAX_ELEMENT_DEPTH / 2)), "two elements a link");
assert!(deep(&chain(5000)));
}
#[test]
fn chains_of_clips_masks_and_patterns_count_by_attribute_style_and_style_sheet() {
let by_attribute = |n: usize| -> String {
(0..n).map(|i| format!("<clipPath id='c{i}' clip-path='url(#c{})'><rect width='1' height='1'/></clipPath>", i + 1)).collect()
};
let by_style = |n: usize| -> String {
(0..n).map(|i| format!("<mask id='c{i}' style='mask: url( \"#c{}\" )'><rect width='1' height='1'/></mask>", i + 1)).collect()
};
let by_sheet = |n: usize| -> String {
let rules: String = (0..n).map(|i| format!(".p{i} {{ fill: url(#c{}) }}", i + 1)).collect();
let patterns: String = (0..n)
.map(|i| format!("<pattern id='c{i}' width='1' height='1'><g class='p{i}'><rect width='1' height='1'/></g></pattern>"))
.collect();
format!("<style>{rules}</style>{patterns}")
};
for chain in [&by_attribute as &dyn Fn(usize) -> String, &by_style, &by_sheet] {
let file =
|n| svg(&format!("<defs>{}</defs><rect width='5' height='5' fill='url(#c0)' clip-path='url(#c0)' mask='url(#c0)'/>", chain(n)));
assert!(!deep(&file(5)));
assert!(deep(&file(MAX_ELEMENT_DEPTH)));
}
}
#[test]
fn many_paint_servers_used_side_by_side_are_not_a_chain() {
// As drawing apps write gradients: a class each, used by objects that aren't inside one another.
let rules: String = (0..300).map(|i| format!(".c{i}{{fill:url(#g{i})}}")).collect();
let defs: String = (0..300).map(|i| format!("<linearGradient id='g{i}'><stop class='c{i}' offset='0'/></linearGradient>")).collect();
let art: String = (0..300).map(|i| format!("<g class='c{i}'><rect class='c{i}' width='1' height='1'/></g>")).collect();
assert!(!deep(&svg(&format!("<style>{rules}</style><defs>{defs}</defs>{art}"))));
}
#[test]
fn a_loop_of_references_is_not_deep() {
assert!(!deep(&svg("<g id='a'><use href='#b'/></g><g id='b'><use href='#a'/></g><clipPath id='c' clip-path='url(#c)'/>")));
}
#[test]
fn an_inherited_paint_is_converted_under_everything_between() {
// Neither the pattern nor the art is too deep; the pattern under the art's last object is.
let half = MAX_ELEMENT_DEPTH / 2;
let pattern = format!("<pattern id='p' width='1' height='1'>{}</pattern>", groups(half));
assert!(!deep(&svg(&format!("<defs>{pattern}</defs><g fill='url(#p)'>{}</g>", groups(half / 2)))));
assert!(deep(&svg(&format!("<defs>{pattern}</defs><g fill='url(#p)'>{}</g>", groups(half)))));
}
}
+10
View File
@@ -203,12 +203,16 @@ impl Files {
ImageHrefResolver {
resolve_data: Box::new(move |mime, bytes, opts| {
if mime == "image/svg+xml" {
if nested_too_deep(&bytes) {
return None;
}
self.note_text(&bytes);
}
data(mime, bytes, opts)
}),
resolve_string: Box::new(move |h, opts| match self.sources.get(h).or_else(|| self.sources.get(h.trim()))? {
Source::Raster(kind, bytes) => Some(kind(bytes.clone())),
Source::Svg(bytes) if nested_too_deep(bytes) => None,
Source::Svg(bytes) => {
self.note_text(bytes);
usvg::Tree::from_data_nested(bytes, opts).ok().map(ImageKind::SVG)
@@ -218,6 +222,12 @@ impl Files {
}
}
/// Is the SVG image `bytes` nested too deep for usvg to read (as the SVG holding it would be)?
/// It is then left out, as an image usvg can't read.
fn nested_too_deep(bytes: &[u8]) -> bool {
crate::text_of(bytes).is_ok_and(|text| super::xml_too_deep(&text))
}
#[cfg(test)]
mod tests {
use super::*;
+11 -3
View File
@@ -312,6 +312,9 @@ pub struct LinkedImage {
pub enum SvgError {
#[error("SVG parse error: {0}")]
Parse(String),
/// Its elements nest (references followed) deeper than the reader goes.
#[error("the SVG's elements are nested more than {0} deep")]
TooDeep(usize),
}
/// Export a document as an SVG string (linked images are not returned: see [`export_full`]).
@@ -347,7 +350,8 @@ pub struct Editing {
/// The native document an SVG carries, if any, and whether the SVG was edited since.
pub fn editing(svg: &str) -> Option<Editing> {
if !svg.contains(EDITING_NS) {
// (An SVG nested too deep for the XML reader carries none: it isn't one of ours.)
if !svg.contains(EDITING_NS) || import::xml_too_deep(svg) {
return None;
}
let opts = usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
@@ -500,14 +504,18 @@ pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), SvgError
import_with(svg, &ImportOptions::default())
}
pub use import::MAX_ELEMENT_DEPTH;
/// Import an SVG document reading the files it links to as `opts` says, also returning warnings.
pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
import::import(svg, opts)
// usvg converts the tree by recursion, in large stack frames: the nesting it is given is
// capped (`import::depth`), and it gets room for that cap whatever thread asks.
vectorcraft_doc::depth::with_deep_stack(|| import::import(svg, opts))
}
/// Reads a linked file: its bytes and the link to it (absolute path, size, time and hash), or
/// `None` when there is no readable file at the (absolute) path.
pub type ReadFile<'a> = &'a dyn Fn(&str) -> Option<(Vec<u8>, vectorcraft_doc::LinkInfo)>;
pub type ReadFile<'a> = &'a (dyn Fn(&str) -> Option<(Vec<u8>, vectorcraft_doc::LinkInfo)> + Sync);
/// How [`import_with`] reads the files an SVG's `<image>` elements link to.
///
+59 -1
View File
@@ -320,6 +320,10 @@ type Catalog = HashMap<String, CatalogFamily>;
/// The installed faces' (family, style) by normalized PostScript name ([`norm`]).
type PostScriptNames = HashMap<String, (String, String)>;
/// Maps the family and style [`FontDb::canonical`] was asked for to its answer, for names that
/// are neither a loaded nor an installed family.
type CanonicalNames = HashMap<(String, String), (String, String)>;
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
@@ -332,6 +336,9 @@ pub struct FontDb {
font_paths: FontPaths,
/// The installed fonts' other names (localized, legacy, full, PostScript) by normalized name.
aliases: RwLock<HashMap<String, Vec<Alias>>>,
/// Keeps [`FontDb::canonical`]'s answers for names other than the loaded and installed
/// families until fonts are added or rescanned.
canonical_cache: Mutex<CanonicalNames>,
/// Set once the font folders have been scanned. Lookups by family name wait for the first
/// scan, so what they find never depends on what ran before them.
#[cfg(not(target_arch = "wasm32"))]
@@ -455,6 +462,9 @@ const SYSTEM_FALLBACKS: &[&str] = &[
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000;
/// How many answers [`FontDb::canonical`] keeps at most. When it has kept that many, it clears
/// them before it keeps the next one.
const CANONICAL_CACHE_MAX: usize = 4_096;
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
@@ -717,6 +727,8 @@ struct Alias {
impl Alias {
/// The aliases of a face `family` / `style` with `keys`, by normalized name.
fn of(family: &str, style: &str, keys: &FaceKeys) -> Vec<(String, Alias)> {
#[cfg(test)]
ALIAS_LISTS.with(|c| c.set(c.get() + 1));
let mut v: Vec<(String, Alias)> =
keys.families.iter().map(|k| (k.clone(), Alias { family: family.to_string(), style: None, paired: None })).collect();
v.extend(
@@ -734,6 +746,18 @@ impl Alias {
}
}
#[cfg(test)]
thread_local! {
/// Tests: counts the faces whose aliases ([`Alias::of`]) were listed on this thread.
static ALIAS_LISTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Tests: returns how many faces' aliases were listed on this thread.
#[cfg(test)]
pub(crate) fn alias_lists() -> u64 {
ALIAS_LISTS.with(std::cell::Cell::get)
}
/// How a requested family and style resolved ([`FontDb::resolve`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FontMatch {
@@ -1467,6 +1491,7 @@ impl FontDb {
postscript: RwLock::new(PostScriptNames::new()),
font_paths,
aliases: RwLock::new(HashMap::new()),
canonical_cache: Mutex::new(CanonicalNames::new()),
#[cfg(not(target_arch = "wasm32"))]
cataloged: std::sync::OnceLock::new(),
#[cfg(not(target_arch = "wasm32"))]
@@ -1509,9 +1534,11 @@ impl FontDb {
self.catalog.read().unwrap_or_else(|e| e.into_inner())
}
/// Fonts were added or the catalog changed: cached family lists are stale.
/// Fonts were added or the catalog changed: cached family lists and [`FontDb::canonical`]'s
/// answers are stale.
fn changed(&self) {
*self.family_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
self.canonical_cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
self.generation.fetch_add(1, Ordering::Relaxed);
}
@@ -1764,6 +1791,24 @@ impl FontDb {
if self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) {
return (family.to_string(), style.to_string());
}
// The lock is held until the answer is kept, and [`FontDb::changed`] waits for it: an
// answer found from the fonts before a change is cleared by that change.
let mut cache = self.canonical_cache.lock().unwrap_or_else(|e| e.into_inner());
let key = (family.to_string(), style.to_string());
if let Some(answer) = cache.get(&key) {
return answer.clone();
}
let answer = self.canonical_alias(family, style);
if cache.len() >= CANONICAL_CACHE_MAX {
cache.clear();
}
cache.insert(key, answer.clone());
answer
}
/// Finds [`FontDb::canonical`]'s answer for a name that is neither a loaded nor an installed
/// family among the loaded faces' and the installed fonts' other names.
fn canonical_alias(&self, family: &str, style: &str) -> (String, String) {
let key = norm(family);
let mut found: Vec<Alias> =
self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).filter(|(k, _)| *k == key).map(|(_, a)| a).collect();
@@ -2230,6 +2275,19 @@ mod alias_tests {
assert!(db.find_postscript("NoSuchFont-Regular").is_none());
assert!(db.has_family("SourceSans3-Regular"));
}
/// The database keeps at most [`CANONICAL_CACHE_MAX`] answers, and names asked for past that
/// many still get the same answers.
#[test]
fn the_answers_kept_for_names_are_capped() {
let db = FontDb::with_font_dirs(vec![]);
for i in 0..=CANONICAL_CACHE_MAX {
let name = format!("No Such Family {i}");
assert_eq!(db.canonical(&name, "Regular"), (name.clone(), "Regular".into()));
}
assert!(db.canonical_cache.lock().unwrap().len() <= CANONICAL_CACHE_MAX);
assert_eq!(db.canonical("SourceSans3-Semibold", "Regular"), ("Source Sans 3".into(), "Semibold".into()));
}
}
#[cfg(test)]
+88
View File
@@ -663,3 +663,91 @@ fn a_full_name_does_not_override_an_installed_family() {
let (face, matched) = db.resolve("Alias Script Italic", "Regular").unwrap();
assert_eq!((face.family.as_str(), face.style.as_str(), matched), ("Alias Script Italic", "Regular", FontMatch::Exact));
}
/// When type is set in a font that isn't installed, layout searches the loaded faces' names for the
/// font's name once until fonts are added.
#[test]
fn a_missing_fonts_name_is_looked_up_once_until_fonts_are_added() {
use crate::fontdb::alias_lists;
let db = FontDb::with_font_dirs(vec![]);
// One lookup lists the names of every loaded face.
let before = alias_lists();
assert_eq!(db.canonical("Gone Sans", "Bold"), ("Gone Sans".to_string(), "Bold".to_string()));
let one_lookup = alias_lists() - before;
let style = vectorcraft_doc::CharStyle { font_family: "Gone Sans".into(), ..Default::default() };
let t = TextObject::point(kurbo::Point::ZERO, &vec!["Set in a font that is not installed."; 20].join("\n"), style);
let before = alias_lists();
for _ in 0..2 {
assert_eq!(layout(&db, &t).lines.len(), 20);
}
assert_eq!(alias_lists() - before, one_lookup, "two layouts of 20 paragraphs list the faces' names once");
// A font added since has the name as its full name.
assert_eq!(db.add_font(named(&[(3, EN, 1, "Found Sans"), (3, EN, 2, "Bold"), (3, EN, 4, "Gone Sans"), (3, EN, 6, "FoundSans-Bold")])), 1);
let l = layout(&db, &t);
assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| db.face_by_id(g.font_id).is_some_and(|f| f.family == "Found Sans")));
}
/// Until an installed font has a name, `canonical` returns the name and the style asked for each
/// time; once a rescan finds the font, the name gives the font's family.
#[test]
fn a_name_asked_for_before_its_font_is_installed_finds_it_after_a_rescan() {
let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-asked-before", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let db = FontDb::with_font_dirs(vec![dir.clone()]);
let answer = |family: &str, style: &str| (family.to_string(), style.to_string());
for _ in 0..2 {
for style in ["Regular", "Bold"] {
assert_eq!(db.canonical("Later Three W3", style), answer("Later Three W3", style));
assert_eq!(db.canonical("後の三", style), answer("後の三", style));
}
}
// The name table is laid out like Hiragino Sans W3's, with a legacy family paired with its
// legacy style.
let three = named(&[
(1, 0, 1, "Later Three"),
(1, 0, 2, "W3"),
(3, EN, 1, "Later Three W3"),
(3, EN, 2, "Regular"),
(3, EN, 6, "LThree-W3"),
(3, EN, 16, "Later Three"),
(3, EN, 17, "W3"),
(3, JA, 16, "後の三"),
(3, JA, 17, "W3"),
]);
std::fs::write(dir.join("LaterThree.ttf"), three).unwrap();
assert_eq!(db.load_system_fonts(), 1);
for _ in 0..2 {
assert_eq!(db.canonical("Later Three W3", "Regular"), answer("Later Three", "W3"));
assert_eq!(db.canonical("Later Three W3", "Bold"), answer("Later Three", "Bold"));
assert_eq!(db.canonical("後の三", "Bold"), answer("Later Three", "Bold"));
}
let _ = std::fs::remove_dir_all(dir);
}
/// Times the layout of a story of 1,000 paragraphs set in a font that is not installed, with 0, 100
/// and 400 fonts added to the bundled ones. Run with
/// `cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`.
#[test]
#[ignore = "manual timing probe; wall-clock performance depends on machine load"]
fn missing_font_story_layout_timing() {
let text = vec!["Type set in a font that is not installed on this computer."; 1000].join("\n");
let style = vectorcraft_doc::CharStyle { font_family: "No Such Font".into(), ..Default::default() };
let t = TextObject::point(kurbo::Point::ZERO, &text, style);
for extra in [0, 100, 400] {
let db = FontDb::with_font_dirs(vec![]);
for i in 0..extra {
let (family, full, postscript) = (format!("Loaded Sans {i}"), format!("Loaded Sans {i} Regular"), format!("LoadedSans{i}-Regular"));
assert_eq!(db.add_font(named(&[(3, EN, 1, &family), (3, EN, 2, "Regular"), (3, EN, 4, &full), (3, EN, 6, &postscript)])), 1);
}
let mut ms = vec![];
for _ in 0..7 {
let start = std::time::Instant::now();
let l = layout(&db, &t);
ms.push(start.elapsed().as_secs_f64() * 1000.0);
assert_eq!(l.lines.len(), 1000);
}
ms.sort_by(f64::total_cmp);
println!("{extra} fonts added, {} families loaded: median {:.1} ms; samples {ms:.1?}", db.family_list().len(), ms[3]);
}
}
+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());
}
+22 -1
View File
@@ -730,7 +730,8 @@ impl Tool for DirectSelectionTool {
}
sel.retain(|(id, _)| cx.doc.is_editable(*id));
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}))]
let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}));
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r)).collect()
}
_ => vec![],
}
@@ -1100,6 +1101,26 @@ mod tests {
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]);
}
#[test]
fn marquee_selects_ruler_guides() {
let (mut d, id) = doc_with_rect();
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
assert_eq!(
a,
vec![
Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"})),
Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})),
]
);
}
/// A path inside an inner group, a sibling path outside it, and the groups' ids.
/// The inside path is the rectangle at (100, 100); the outside one at (300, 300).
fn nested_group_doc() -> (vectorcraft_doc::Document, NodeId, NodeId, NodeId, NodeId) {
+32 -2
View File
@@ -2,14 +2,15 @@
//! made where it is released over the canvas: with the Artboard tool, an artboard guide of the
//! active artboard. For the selection tools ([`GuideEdit`]), a press on a guide selects it (Shift
//! adds it to the selected guides or, released without a drag, takes it out); dragging moves the
//! selected guides (Alt copies them). A dragged guide snaps ([`GuideSnap`]): with Shift to the
//! selected guides (Alt copies them), and a marquee selects the guides it crosses along with the
//! art ([`marquee_select`]). A dragged guide snaps ([`GuideSnap`]): with Shift to the
//! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards'
//! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its
//! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden
//! or locked layer, can't be picked.
use serde_json::json;
use vectorcraft_geom::Point;
use vectorcraft_geom::{Point, Rect};
use crate::guides::Targets;
use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext};
@@ -32,6 +33,35 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option<usize> {
.map(|(i, _)| i)
}
/// What a selection tool's marquee `r` does to the ruler guides, after its action selecting the
/// art: the guides it crosses join the selection (with a Shift marquee, the selected ones leave
/// it, as with a Shift-click on a guide).
pub fn marquee_select(cx: &ToolContext, r: Rect) -> Option<Action> {
let guides = guides_in_rect(cx, r);
(!guides.is_empty()).then(|| Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true })))
}
/// The editable ruler guides intersecting marquee rectangle `r`.
fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec<usize> {
if !cx.guides {
return vec![];
}
cx.doc
.guides
.iter()
.enumerate()
.filter(|(_, g)| cx.doc.guide_editable(g))
.filter(|(_, g)| {
if g.vertical {
r.min_x() <= g.pos && g.pos <= r.max_x() && cx.doc.guide_span(g).is_none_or(|(y0, y1)| r.max_y() >= y0 && r.min_y() <= y1)
} else {
r.min_y() <= g.pos && g.pos <= r.max_y() && cx.doc.guide_span(g).is_none_or(|(x0, x1)| r.max_x() >= x0 && r.min_x() <= x1)
}
})
.map(|(i, _)| i)
.collect()
}
/// The pointer for a guide: it moves sideways if vertical, up and down if horizontal.
fn guide_cursor(vertical: bool) -> Cursor {
if vertical { Cursor::ResizeH } else { Cursor::ResizeV }
+54 -1
View File
@@ -459,7 +459,8 @@ impl Tool for SelectionTool {
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
}
let ids: Vec<NodeId> = marquee(cx.doc, r, cx.isolation, false);
vec![Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) }))]
let art = Action::Exec(if toggle { "select.toggle" } else { "select.set" }.into(), json!({ "ids": json_ids(&ids) }));
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r)).collect()
}
_ => vec![],
}
@@ -993,6 +994,58 @@ mod tests {
assert!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0).with_mods(shift)).is_empty());
}
#[test]
fn marquee_selects_ruler_guides() {
let (mut d, id) = doc_with_rect();
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 120.0));
let a = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 120.0));
assert_eq!(
a,
vec![
Action::Exec("select.set".into(), json!({"ids": [id.0]})),
Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})),
]
);
}
#[test]
fn marquee_selects_only_guide() {
let (mut d, _) = doc_with_rect();
d.guides.push(vectorcraft_doc::Guide::new(true, 20.0));
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = SelectionTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 10.0, 10.0));
t.pointer(&cx, &ev(PointerKind::Drag, 30.0, 30.0));
let a = t.pointer(&cx, &ev(PointerKind::Up, 30.0, 30.0));
assert_eq!(
a,
vec![Action::Exec("select.set".into(), json!({"ids": []})), Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})),]
);
}
#[test]
fn marquee_ignores_locked_guides() {
let (mut d, id) = doc_with_rect();
d.guides.push(vectorcraft_doc::Guide::new(false, 80.0));
let s = Selection::default();
let p = paint();
let mut cx_locked = cx(&d, &s, &p);
cx_locked.guides = false;
let mut t = SelectionTool::default();
t.pointer(&cx_locked, &ev(PointerKind::Down, 50.0, 50.0));
t.pointer(&cx_locked, &ev(PointerKind::Drag, 120.0, 120.0));
let a = t.pointer(&cx_locked, &ev(PointerKind::Up, 120.0, 120.0));
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
}
#[test]
fn handle_drag_scales() {
let (d, id) = doc_with_rect();
+36 -8
View File
@@ -36,6 +36,8 @@ pub(crate) struct Line {
/// Stroke width in pixels.
pub width: f64,
pub pixels: usize,
/// Its ends are cut square (the ink is as long beside the line's middle as along it), not round.
pub square: bool,
}
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
@@ -374,22 +376,48 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64;
// wide as its pixels make it).
widths.sort_by(f64::total_cmp);
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
// Each end goes where the ink ends, less the half width its round cap adds.
// Each end goes where the ink ends, less the half width its cap adds. The cap is square when
// the ends wide enough to tell are, taken together: the ink reaches as far a third of the width to either side
// of the line's middle as along it, where a round end stops short by a seventh of the width.
let (mut short_by, mut told) = (0.0, 0);
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
for k in 0..2 {
if r.ends[k].is_some_and(|e| meet[e] > 1) {
continue;
}
let n = r.pts.len();
let (tip, inner) = if k == 0 { (r.pts[0], r.pts[3.min(n - 1)]) } else { (r.pts[n - 1], r.pts[n - 1 - 3.min(n - 1)]) };
let d = tip - inner;
let at = |i: usize| if k == 0 { i.min(n - 1) } else { n - 1 - i.min(n - 1) };
// The last points of a wide line's skeleton turn towards a corner of its end: the end
// lies where the line was going a width back, and they are put on that line.
let back = (width.round() as usize + 2).max(3);
let (tip, inner) = (r.pts[at(0)], r.pts[at(back)]);
// (Only an end that has left that line: over so few pixels the line's direction is known
// to a few degrees at best, less without anti-aliasing, and an end already on it is
// better left where it is.)
let before = inner - r.pts[at(2 * back)];
let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot();
let d = if wide { before } else { tip - inner };
if d.hypot() < 1e-9 {
continue;
}
let more = ink.beyond(tip, d / d.hypot()) - width / 2.0;
if more.abs() > 0.05 && more > -1.0 {
let to = tip + d / d.hypot() * more;
if k == 0 { r.pts[0] = to } else { r.pts[n - 1] = to }
let along = d / d.hypot();
let on_line = |p: Point| inner + along * (p - inner).dot(along).max(0.0);
if wide {
for i in 0..back {
r.pts[at(i)] = on_line(r.pts[at(i)]);
}
}
let tip = on_line(tip);
let reach = ink.beyond(tip, along);
if width >= 3.0 {
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
let short = reach - (ink.beyond(tip + aside, along) + ink.beyond(tip - aside, along)) / 2.0;
short_by += short / width;
told += 1;
}
let more = reach - width / 2.0;
if more.abs() > 0.05 && more > 1.0 - (tip - inner).hypot() {
r.pts[at(0)] = tip + along * more;
}
}
}
@@ -409,7 +437,7 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64;
if subs.is_empty() {
return None;
}
Some(Line { path: PathData::new(subs), width, pixels: comp.len() })
Some(Line { path: PathData::new(subs), width, pixels: comp.len(), square: told > 0 && short_by / f64::from(told) < 0.07 })
}
/// What the image says of a line's ink.
+2 -2
View File
@@ -188,7 +188,7 @@ pub(crate) fn fit_loop(
across: &dyn Fn(Crack) -> (f64, u32),
junction_at: &dyn Fn(i32, i32) -> Option<Point>,
style: &Style,
) -> Option<SubPath> {
) -> Option<(SubPath, bool)> {
let pieces = subpixel::outline(l, w, h, across, junction_at);
let mut segs: Vec<Cubic> = Vec::new();
for piece in &pieces {
@@ -206,7 +206,7 @@ pub(crate) fn fit_loop(
segs.extend(fit_piece(&piece.pts, &style, true));
}
}
subpath(&segs, true)
Some((subpath(&segs, true)?, pieces.iter().any(|p| p.hard)))
}
/// A chain of cubics as a subpath: closed (its end joins its start) or open. A straight piece has
+49 -15
View File
@@ -25,6 +25,8 @@ const MAX_STOPS: usize = 6;
const MIN_SPACING: f64 = 0.08;
/// How far (levels) the stops may be from the colours along the gradient.
const STOP_TOLERANCE: f64 = 2.0;
/// How far (levels) a pixel beside a gradient may be from the gradient's colour there and join it.
const GROW: f64 = 10.0;
/// A linear gradient a traced path is filled with.
#[derive(Clone, Debug, PartialEq)]
@@ -52,6 +54,12 @@ impl TracedGradient {
}
}
/// The pixels beside pixel `i` of a `w` × `h` image.
fn beside(i: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
let (x, y) = (i % w, i / w);
[(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten()
}
/// The gradients of `img`: for each pixel the gradient it is part of (an index into the list, or
/// `u32::MAX`), and the list.
pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
@@ -103,11 +111,8 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
let mut depth = vec![0u16; w * h];
let mut found = vec![];
let (mut members, mut stack) = (vec![], vec![]);
for seed in 0..w * h {
if seen.get(seed).copied().unwrap_or(true) || !smooth.get(seed).copied().unwrap_or(false) {
continue;
}
// The connected smooth pixels from here.
// The connected smooth pixels from `seed` that `seen` has not: into `members`.
let mut flood = |seed: usize, seen: &mut [bool], members: &mut Vec<usize>| {
members.clear();
stack.push(seed);
if let Some(s) = seen.get_mut(seed) {
@@ -115,8 +120,7 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
}
while let Some(i) = stack.pop() {
members.push(i);
let (x, y) = (i % w, i / w);
for j in [(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten() {
for j in beside(i, w, h) {
if smooth.get(j).copied().unwrap_or(false) && !seen.get(j).copied().unwrap_or(true) {
if let Some(s) = seen.get_mut(j) {
*s = true;
@@ -125,7 +129,26 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
}
}
}
if members.len() < MIN_PIXELS || found.len() >= 1000 {
};
// The areas, largest first: a gradient that noise or a soft rim broke into several is found in
// its largest piece and grown over the rest.
let mut areas: Vec<(usize, usize)> = vec![];
for seed in 0..w * h {
if !seen.get(seed).copied().unwrap_or(true) && smooth.get(seed).copied().unwrap_or(false) {
flood(seed, &mut seen, &mut members);
if members.len() >= MIN_PIXELS {
areas.push((members.len(), seed));
}
}
}
areas.sort_unstable_by(|a, b| b.cmp(a));
seen.fill(false);
for (_, seed) in areas {
if seen.get(seed).copied().unwrap_or(true) || found.len() >= 1000 {
continue;
}
flood(seed, &mut seen, &mut members);
if members.len() < MIN_PIXELS {
continue;
}
// The gradient is read from the area's inside, where there is one: the pixels at its rim
@@ -135,16 +158,12 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
let rim = far as usize + 2;
let unknown = u16::MAX;
members.iter().for_each(|&i| depth[i] = unknown);
let beside = |i: usize| {
let (x, y) = (i % w, i / w);
[(x > 0).then(|| i - 1), (x + 1 < w).then(|| i + 1), (y > 0).then(|| i - w), (y + 1 < h).then(|| i + w)].into_iter().flatten()
};
let mut front: Vec<usize> = members.iter().copied().filter(|&i| beside(i).any(|j| depth.get(j).copied() == Some(0))).collect();
let mut front: Vec<usize> = members.iter().copied().filter(|&i| beside(i, w, h).any(|j| depth.get(j).copied() == Some(0))).collect();
front.iter().for_each(|&i| depth[i] = 1);
for d in 2..=rim as u16 {
let mut next = vec![];
for &i in &front {
for j in beside(i) {
for j in beside(i, w, h) {
if let Some(slot) = depth.get_mut(j).filter(|s| **s == unknown) {
*slot = d;
next.push(j);
@@ -156,12 +175,27 @@ pub(crate) fn detect(img: &Raster) -> (Vec<u32>, Vec<TracedGradient>) {
let inside: Vec<usize> = members.iter().copied().filter(|&i| depth.get(i).copied() == Some(unknown)).collect();
let fitted = fit(img, w, if inside.len() >= MIN_PIXELS { &inside } else { &members }, &members, rim);
members.iter().for_each(|&i| depth[i] = 0);
if let Some((g, part)) = fitted {
if let Some((g, mut part)) = fitted {
for &i in &part {
if let Some(o) = of.get_mut(i) {
*o = found.len() as u32;
}
}
// Grown over every pixel next to it that has its colour there: the pixels noise put a
// little off, a soft rim, the pieces noise cut off it.
while let Some(i) = part.pop() {
for j in beside(i, w, h) {
let near = px(j).is_some_and(|c| {
let own = g.color_at(Point::new((j % w) as f64 + 0.5, (j / w) as f64 + 0.5));
(0..3).all(|k| (f64::from(c[k]) - own[k]).abs() <= GROW)
});
if near && of.get(j).copied() == Some(u32::MAX) {
of[j] = found.len() as u32;
seen[j] = true;
part.push(j);
}
}
}
found.push(g);
}
}
+3 -3
View File
@@ -77,8 +77,8 @@ fn inside_or_near(p: Point, o: &Outer) -> bool {
/// path whose outer contour holds all of its own. The path that covers the whole `w` × `h` image
/// (the paper) is nobody's parent: everything would be inside it.
///
/// Drawing each path followed by those inside it paints the picture `paths` paints in its own
/// order: where the paths can overlap (`stacked`), a path only gets a parent below it, and only
/// Drawing each path together with those inside it (in their own order) paints the picture
/// `paths` paints in its own order: where the paths can overlap (`stacked`), a path only gets a parent below it, and only
/// if no path that would then come after it instead of before it shares any of its box.
pub(crate) fn parents(paths: &[TracedPath], w: usize, h: usize, stacked: bool) -> Vec<Option<usize>> {
let outers: Vec<Option<Outer>> = paths.iter().map(outer).collect();
@@ -158,7 +158,7 @@ mod tests {
fn disc(c: (f64, f64), r: f64, color: u8) -> TracedPath {
let path = PathData::from_bezpath(&kurbo::Shape::to_path(&kurbo::Circle::new(c, r), 0.01));
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None }
TracedPath { path, color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None, square_caps: false }
}
#[test]
+78 -8
View File
@@ -430,6 +430,14 @@ pub fn presets() -> Vec<(&'static str, TraceParams)> {
PRESET_NAMES.iter().filter_map(|n| Some((*n, preset(n)?))).collect()
}
/// The enhanced version of built-in preset `name`: the preset with the options the enhanced
/// presets have built in: Auto Grouping in all of them, Shapes in all but High Fidelity Photo.
/// Gradients stay an option to turn on. Flat Logo, a preset of our own, has none.
pub fn enhanced_preset(name: &str) -> Option<TraceParams> {
let p = preset(name).filter(|p| p.mode != Mode::Logo)?;
Some(TraceParams { auto_grouping: true, shapes: !name.eq_ignore_ascii_case("High Fidelity Photo"), ..p })
}
/// One traced shape: an outer contour plus its holes, filled with `color`, or (with `stroke`)
/// lines stroked with it.
#[derive(Clone, Debug, PartialEq)]
@@ -446,6 +454,9 @@ pub struct TracedPath {
pub shape: Option<TracedShape>,
/// Gradients: the linear gradient the path is filled with (`color` is its middle colour).
pub gradient: Option<TracedGradient>,
/// Create Strokes: the stroke's ends are square (a cap that goes half the width past the
/// path's end, cut straight), as the line's ink is; otherwise round.
pub square_caps: bool,
}
/// The traced result, bottom-most path first.
@@ -455,7 +466,8 @@ pub struct TraceResult {
/// Colours actually used.
pub palette: Vec<[u8; 3]>,
/// Auto Grouping: for each of `paths`, the path it lies inside, if any (empty without the
/// option). Each path drawn before those inside it paints what `paths` paints in its order.
/// option). Each path drawn together with those inside it, in the order they have here, paints
/// what `paths` paints in its order.
pub parents: Vec<Option<usize>>,
}
@@ -810,6 +822,36 @@ impl Layers<'_> {
shift
}
/// Whether the pixels inside `l` (a loop along pixel edges) all have one label.
fn one_colour_within(&self, l: &Loop) -> bool {
let mut first = None;
let (y0, y1) = l.pts.iter().fold((i32::MAX, i32::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1)));
for y in y0..y1 {
// The loop's upright edges that pass this row of pixels, left to right: the pixels
// between the first and the second are inside, and so on.
let mut xs: Vec<i32> = l
.pts
.iter()
.zip(l.pts.iter().cycle().skip(1))
.filter(|(a, b)| a.0 == b.0 && a.1.min(b.1) <= y && y < a.1.max(b.1))
.map(|(a, _)| a.0)
.collect();
xs.sort_unstable();
for pair in xs.as_chunks::<2>().0 {
for x in pair[0]..pair[1] {
let label = usize::try_from(y)
.ok()
.zip(usize::try_from(x).ok())
.and_then(|(y, x)| self.q.labels.get(y * self.w + x).copied().filter(|_| x < self.w));
if label.is_none() || *first.get_or_insert(label) != label {
return false;
}
}
}
}
first.is_some()
}
/// The paths of layer `r` (palette entry `ci`), or `None` once the anchors made pass the
/// maximum.
fn layer(&self, r: usize, ci: usize) -> Option<Vec<TracedPath>> {
@@ -846,14 +888,14 @@ impl Layers<'_> {
let area_stroke = (params.strokes && !params.fills).then_some(1.0);
let min_hole = params.noise.max(1) as i64;
let mut paths = vec![];
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>, shape: Option<TracedShape>| {
let mut add = |path: PathData, pixels: usize, stroke: Option<f64>, shape: Option<TracedShape>, square_caps: bool| {
let n = path.anchor_count();
if self.anchors.fetch_add(n, Ordering::Relaxed).saturating_add(n) > self.max_anchors {
return false;
}
// (A gradient fills; a stroked line of it is its middle colour.)
let gradient = self.gradients.get(ci).cloned().flatten().filter(|_| stroke.is_none());
paths.push(TracedPath { path, color, pixels, stroke, shape, gradient });
paths.push(TracedPath { path, color, pixels, stroke, shape, gradient, square_caps });
true
};
let mut comps = trace_mask(&mask, bw, bh);
@@ -866,27 +908,55 @@ impl Layers<'_> {
}
}
for comp in comps {
let fit = |l: &Loop| fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style);
// (`hard`: some of the outline has no anti-aliasing, and is known less exactly.)
let hard = std::cell::Cell::new(false);
let fit = |l: &Loop| {
let (sp, is_hard) = fit::fit_loop(l, w, h, &|c| (self.shift(c), self.neighbour(c)), &|x, y| self.junction(x, y), &self.style)?;
hard.set(hard.get() || is_hard);
Some(sp)
};
let Some(outer) = fit(&comp.outer) else { continue };
let mut subs = vec![outer];
for hole in &comp.holes {
if hole.area2.abs() / 2 < min_hole && params.noise > 0 {
continue;
}
subs.extend(fit(hole));
let Some(mut sp) = fit(hole) else { continue };
// Shapes: a hole that is a shape is that shape, as what fills it is (the two are
// one outline: they stay one). Only where one colour fills it: the parts of
// several are not that shape, and would no longer meet it.
if params.shapes
&& area_stroke.is_none()
&& self.one_colour_within(hole)
&& let Some(exact) = shapes::recognise(&sp, hard.get()).and_then(|shape| shape.path().subpaths.into_iter().next())
{
// (Which way round each goes: by the area of the polygon through points along it.)
let turn = |sp: &vectorcraft_geom::SubPath| {
use vectorcraft_geom::ParamCurve;
let pts: Vec<_> =
(0..sp.segment_count()).flat_map(|i| (0..6).map(move |k| sp.segment(i).eval(f64::from(k) / 6.0).to_vec2())).collect();
pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.cross(*b)).sum::<f64>()
};
let mut exact = exact;
if (turn(&exact) < 0.0) != (turn(&sp) < 0.0) {
exact.reverse();
}
sp = exact;
}
subs.push(sp);
}
// Shapes: a filled outline without holes that is a shape becomes that shape.
let shape = match subs.as_slice() {
[only] if params.shapes && area_stroke.is_none() => shapes::recognise(only),
[only] if params.shapes && area_stroke.is_none() => shapes::recognise(only, hard.get()),
_ => None,
};
let path = shape.map_or_else(|| PathData::new(subs), |s| s.path());
if !add(path, comp.pixels, area_stroke, shape) {
if !add(path, comp.pixels, area_stroke, shape, false) {
return None;
}
}
for line in lines {
if !add(line.path, line.pixels, Some(line.width), None) {
if !add(line.path, line.pixels, Some(line.width), None, line.square) {
return None;
}
}
+1
View File
@@ -493,6 +493,7 @@ pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize)
stroke: None,
shape: None,
gradient: None,
square_caps: false,
});
}
Ok(out)
+105 -56
View File
@@ -37,13 +37,18 @@ impl TracedShape {
}
}
/// How near (px) a sharp corner the outline may fall short of it, and by how much more than
/// elsewhere: anti-aliasing and blur take that much off a corner.
const CORNER: f64 = 3.0;
const CORNER_SLACK: f64 = 0.75;
/// How far a point may lie from the shape: half a pixel, and for large shapes 0.2% of their size.
fn tolerance(size: f64) -> f64 {
(0.002 * size).max(0.5)
}
/// The shape the closed outline `sp` is, if it is one (and at least 6 px across).
pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
pub(crate) fn recognise(sp: &SubPath, hard: bool) -> Option<TracedShape> {
if !sp.closed || sp.anchors.len() < 2 {
return None;
}
@@ -83,7 +88,8 @@ pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
// The direction of the longer axis (none when the shape is as wide every way).
let axis = if spread < 0.01 * mean { 0.0 } else { 0.5 * (2.0 * sxy).atan2(sxx - syy) };
let size = 4.0 * major.sqrt();
let tol = tolerance(size);
// (An outline without anti-aliasing is only known to about half a pixel.)
let tol = tolerance(size) * if hard { 1.5 } else { 1.0 };
if size < 6.0 || !size.is_finite() {
return None;
}
@@ -110,64 +116,107 @@ pub(crate) fn recognise(sp: &SubPath) -> Option<TracedShape> {
}
}
// A rectangle, along its longest straight side (a square has no longer axis to go by).
// A rectangle. Its sides run along its longest straight side if it has one, else along the
// longer axis of its area (a blurred one may have no side that is straight), or the pixels.
let quarter = std::f64::consts::FRAC_PI_2;
let folded = |a: f64| a - quarter * (a / quarter).round();
let side = (0..sp.segment_count())
.filter(|&i| sp.segment_is_line(i))
.map(|i| sp.segment(i))
.max_by(|a, b| a.p0.distance(a.p3).total_cmp(&b.p0.distance(b.p3)));
if let Some(side) = side {
let d = side.p3 - side.p0;
// Turned by under 45° either way: a rectangle is the same a quarter turn on.
let quarter = std::f64::consts::FRAC_PI_2;
let angle = d.y.atan2(d.x);
let angle = angle - quarter * (angle / quarter).round();
.max_by(|a, b| a.p0.distance(a.p3).total_cmp(&b.p0.distance(b.p3)))
.map(|s| folded((s.p3 - s.p0).y.atan2((s.p3 - s.p0).x)));
for angle in side.into_iter().chain([folded(axis), 0.0]) {
let xy: Vec<(f64, f64)> = pts.iter().map(|&p| local(p, angle)).collect();
let (hw, hh) = xy.iter().fold((0.0f64, 0.0f64), |m, p| (m.0.max(p.0.abs()), m.1.max(p.1.abs())));
// Square corners first; else rounded ones, which take (4 − π) r² off the area.
let rounded = ((4.0 * hw * hh - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt().min(hw.min(hh));
for radius in [0.0, rounded] {
let off = xy.iter().map(|&(x, y)| {
// Distance to the rounded rectangle's edge.
// (Its half sizes: where its sides lie on average, not where their farthest points do.)
let side = |far: f64, upright: bool, other: f64| {
let on: Vec<f64> = xy
.iter()
.map(|p| if upright { (p.0, p.1) } else { (p.1, p.0) })
.filter(|&(a, b)| a.abs() >= far - 0.5 && b.abs() <= other - CORNER)
.map(|(a, _)| a.abs())
.collect();
if on.len() >= 4 { on.iter().sum::<f64>() / on.len() as f64 } else { far }
};
let (hw, hh) = (side(hw, true, hh), side(hh, false, hw));
if hw <= 2.0 || hh <= 2.0 {
continue;
}
// How far the outline is from the rectangle with corners of `radius`, at worst; the points
// by its corners `slack` nearer than they are.
let off = |radius: f64, slack: f64| {
let each = xy.iter().map(|&(x, y)| {
let (qx, qy) = (x.abs() - hw + radius, y.abs() - hh + radius);
(qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius).abs()
let d = (qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius).abs();
if x.abs() > hw - CORNER && y.abs() > hh - CORNER { (d - slack).max(0.0) } else { d }
});
if hw > 2.0 && hh > 2.0 && (radius == 0.0 || radius >= 0.75) && off.fold(0.0, f64::max) <= tol {
return Some(TracedShape::Rectangle { center: c, width: 2.0 * hw, height: 2.0 * hh, radius, rotation: angle.to_degrees() });
}
each.fold(0.0, f64::max)
};
let rectangle = |radius: f64| TracedShape::Rectangle { center: c, width: 2.0 * hw, height: 2.0 * hh, radius, rotation: angle.to_degrees() };
// Square corners (anti-aliasing and blur take a little off a corner: that is no radius).
// (Not of a thin bar: it is all corners, and would pass for a rectangle whatever it is.)
if off(0.0, if hw.min(hh) >= 2.0 * CORNER { CORNER_SLACK } else { 0.0 }) <= tol {
return Some(rectangle(0.0));
}
// Rounded ones: the radius that fits best, near the one the area says ((4 − π) r² is missing).
let by_area = ((4.0 * hw * hh - area) / (4.0 - std::f64::consts::PI)).max(0.0).sqrt();
let radii = (0..=40).map(|k| by_area + (f64::from(k) - 20.0) * 0.15).filter(|r| (CORNER..=hw.min(hh)).contains(r));
if let Some((_, radius)) = radii.map(|r| (off(r, 0.0), r)).min_by(|a, b| a.0.total_cmp(&b.0)).filter(|best| best.0 <= tol) {
return Some(rectangle(radius));
}
}
// A regular polygon: its corners evenly round the middle, its sides straight between them.
let sides = sp.anchors.len();
if (3..=12).contains(&sides) {
let radius = sp.anchors.iter().map(|a| a.p.distance(c)).sum::<f64>() / sides as f64;
let first = sp.anchors.first()?.p - c;
let rotation = (first.y.atan2(first.x).to_degrees() + 90.0).rem_euclid(360.0 / sides as f64);
let shape = TracedShape::Polygon { center: c, radius, sides: sides as u32, rotation };
let ideal: Vec<Point> = shape.path().subpaths.first()?.anchors.iter().map(|a| a.p).collect();
// Every corner of the polygon has one of the outline's on it, and every point of the
// outline lies on one of the polygon's sides.
let cornered = ideal.iter().all(|q| sp.anchors.iter().any(|a| q.distance(a.p) <= 1.5 * tol));
let on_a_side = |p: Point| {
(0..ideal.len()).any(|i| {
let (a, b) = (ideal[i], ideal[(i + 1) % ideal.len()]);
let t = ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
p.distance(a + (b - a) * t) <= tol
})
// A regular polygon: going round it, its outline is nearest its middle once a side and farthest
// once a corner. The sides are as many as that happens most strongly for, the corners where
// it is farthest, the size what the area says.
let polar: Vec<(f64, f64)> = pts.iter().map(|&p| ((p - c).hypot(), (p - c).y.atan2((p - c).x))).collect();
let weights: Vec<f64> = (0..n).map(|i| (polar[(i + 1) % n].1 - polar[i].1).sin().abs()).collect();
let mean = polar.iter().zip(&weights).map(|(p, w)| p.0 * w).sum::<f64>() / weights.iter().sum::<f64>().max(1e-9);
let wave = |sides: u32| {
polar.iter().zip(&weights).fold((0.0, 0.0), |m, (p, w)| {
(m.0 + (p.0 - mean) * w * (f64::from(sides) * p.1).cos(), m.1 + (p.0 - mean) * w * (f64::from(sides) * p.1).sin())
})
};
let sides = (3..=12u32).max_by(|&a, &b| wave(a).0.hypot(wave(a).1).total_cmp(&wave(b).0.hypot(wave(b).1)))?;
let (re, im) = wave(sides);
let corner = im.atan2(re) / f64::from(sides);
// (More exactly: by the outline's own corners, where it has them there.)
let step = 2.0 * std::f64::consts::PI / f64::from(sides);
let turns: Vec<f64> = sp
.anchors
.iter()
.map(|a| ((a.p - c).y.atan2((a.p - c).x) - corner, (a.p - c).hypot()))
.map(|(d, far)| (d - step * (d / step).round(), far))
.filter(|&(d, far)| (d * far).abs() <= CORNER && far > 0.9 * mean)
.map(|(d, _)| d)
.collect();
let corner = corner + turns.iter().sum::<f64>() / turns.len().max(1) as f64;
let radius = (2.0 * area / (f64::from(sides) * (2.0 * std::f64::consts::PI / f64::from(sides)).sin())).sqrt();
let rotation = (corner.to_degrees() + 90.0).rem_euclid(360.0 / f64::from(sides));
let shape = TracedShape::Polygon { center: c, radius, sides, rotation };
let ideal: Vec<Point> = shape.path().subpaths.first()?.anchors.iter().map(|a| a.p).collect();
// Every point of the outline lies on one of the polygon's sides (by its corners, nearly).
let side_length = 2.0 * radius * (std::f64::consts::PI / f64::from(sides)).sin();
let on_a_side = |p: Point| {
let slack = if side_length >= 4.0 * CORNER && ideal.iter().any(|q| q.distance(p) <= CORNER) { CORNER_SLACK } else { 0.0 };
(0..ideal.len()).any(|i| {
let (a, b) = (ideal[i], ideal[(i + 1) % ideal.len()]);
let t = ((p - a).dot(b - a) / (b - a).hypot2()).clamp(0.0, 1.0);
p.distance(a + (b - a) * t) <= tol + slack
})
};
if radius > 3.0 && pts.iter().all(|&p| on_a_side(p)) {
// Four equal sides are a square: a rectangle, its sides half a right angle from its corners.
let side = radius * std::f64::consts::SQRT_2;
let turned = (rotation + 45.0).rem_euclid(90.0);
let square = TracedShape::Rectangle {
center: c,
width: side,
height: side,
radius: 0.0,
rotation: if turned > 45.0 { turned - 90.0 } else { turned },
};
if cornered && pts.iter().all(|&p| on_a_side(p)) {
// Four equal sides are a square: a rectangle, its sides half a right angle from its corners.
let side = radius * std::f64::consts::SQRT_2;
let turned = (rotation + 45.0).rem_euclid(90.0);
let square = TracedShape::Rectangle {
center: c,
width: side,
height: side,
radius: 0.0,
rotation: if turned > 45.0 { turned - 90.0 } else { turned },
};
return Some(if sides == 4 { square } else { shape });
}
return Some(if sides == 4 { square } else { shape });
}
None
}
@@ -177,7 +226,7 @@ mod tests {
use super::*;
fn is(shape: TracedShape) -> Option<TracedShape> {
recognise(shape.path().subpaths.first().unwrap())
recognise(shape.path().subpaths.first().unwrap(), false)
}
#[test]
@@ -221,20 +270,20 @@ mod tests {
fn what_only_resembles_a_shape_is_none() {
// A star, an L, a rectangle with one corner cut, an egg.
let star = shapes::star(Point::new(50.0, 50.0), 30.0, 12.0, 5, 0.0);
assert_eq!(recognise(&star.subpaths[0]), None);
assert_eq!(recognise(&star.subpaths[0], false), None);
let poly = |pts: &[(f64, f64)]| SubPath::polyline(&pts.iter().map(|&p| Point::from(p)).collect::<Vec<_>>(), true);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 15.0), (15.0, 15.0), (15.0, 40.0), (0.0, 40.0)])), None);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 22.0), (34.0, 30.0), (0.0, 30.0)])), None);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 15.0), (15.0, 15.0), (15.0, 40.0), (0.0, 40.0)]), false), None);
assert_eq!(recognise(&poly(&[(0.0, 0.0), (40.0, 0.0), (40.0, 22.0), (34.0, 30.0), (0.0, 30.0)]), false), None);
let mut egg = shapes::ellipse(Rect::new(10.0, 10.0, 70.0, 50.0));
egg.subpaths[0].anchors[2].p.x += 4.0;
assert_eq!(recognise(&egg.subpaths[0]), None);
assert_eq!(recognise(&egg.subpaths[0], false), None);
// Too small to say, open, or not a number.
assert_eq!(is(TracedShape::Ellipse { center: Point::new(5.0, 5.0), rx: 2.0, ry: 2.0, rotation: 0.0 }), None);
let mut open = shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)).subpaths[0].clone();
open.closed = false;
assert_eq!(recognise(&open), None);
assert_eq!(recognise(&open, false), None);
let mut bad = shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)).subpaths[0].clone();
bad.anchors[1].p.x = f64::NAN;
assert_eq!(recognise(&bad), None);
assert_eq!(recognise(&bad, false), None);
}
}
+30
View File
@@ -1265,3 +1265,33 @@ fn a_long_line_two_degrees_askew_is_not_levelled() {
// 200 px at 2° rises 7 px: levelled, the bar would be 8 px tall, not 15.
assert!(b.height() > 13.0, "{b:?}");
}
/// Create Strokes: a line cut square at its ends is stroked with square caps, a line with round
/// ends with round ones, and either ends where its ink does.
#[test]
fn stroke_caps_are_square_or_round_as_the_ink_is() {
let line = |square: bool| {
let img = Raster::from_fn(140, 40, |x, y| {
// Anti-aliased by 4 × 4 samples: a bar 8 px thick from x = 20 to 110.
let mut inside = 0;
for sy in 0..4 {
for sx in 0..4 {
let (px, py) = (f64::from(x) + (f64::from(sx) + 0.5) / 4.0, f64::from(y) + (f64::from(sy) + 0.5) / 4.0);
let d = if square { (py - 20.3).abs().max((px - 65.0).abs() - 41.0) } else { (px.clamp(24.0, 106.0) - px).hypot(py - 20.3) };
inside += u32::from(d <= 4.0);
}
}
let v = (255 - inside * 255 / 16) as u8;
[v, v, v, 255]
});
let res = trace(&img, &crate::preset("Outline").unwrap());
let [only] = res.paths.as_slice() else { panic!("{:?}", res.paths) };
let (a, b) = (only.path.subpaths[0].anchors[0].p, only.path.subpaths[0].anchors.last().unwrap().p);
// Either cap goes half the width past the path's end: the path runs from 24 to 106.
assert!((a.x.min(b.x) - 24.0).abs() < 0.6 && (a.x.max(b.x) - 106.0).abs() < 0.6, "{a:?} {b:?}");
assert!((only.stroke.unwrap() - 8.0).abs() < 0.4);
only.square_caps
};
assert!(line(true));
assert!(!line(false));
}
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more