Compare commits

..
Author SHA1 Message Date
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
109 changed files with 4297 additions and 626 deletions

No files matched your search

Generated
+31 -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"
@@ -4986,7 +5002,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 +5017,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",
+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();
+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 })
}
+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(crate) 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(crate) 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());
+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`]).
+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]
+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,
+638 -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,620 @@ 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> {
let z = deflated(data.as_bytes());
// (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(),
],
),
],
);
}
#[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");
});
}
/// 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' => {
+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)]
+2 -2
View File
@@ -23,8 +23,8 @@ 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"
serde = { workspace = true }
+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))
})
}
+115 -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,8 +37,112 @@ 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
})
}
/// 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 is refused here ([`nests_without_end`]).
pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result<Pdf, PdfError> {
if is_postscript(bytes) {
return Err(PdfError::PostScript);
@@ -55,6 +163,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).any(|_| nests_without_end(&pdf)) {
true => Err(PdfError::Parse("its functions or colour spaces are built on one another without end".into())),
false => Ok(pdf),
})
}
/// The pages and their boxes. An encrypted PDF needs its `password`
+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()
}
+66 -16
View File
@@ -366,6 +366,29 @@ fn linear_key(view: Affine) -> [u64; 4] {
[c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()]
}
/// The raster a shadow or glow blurred by `blur` device pixels is filtered in, for content
/// reaching `reach` (device pixels) in a view of `screen` pixels: (left, top, width, height,
/// scale). It covers what of the reach can show in the view, the blur's spread around it
/// included, at `scale` raster pixels a device pixel: 1, or less to stay within
/// [`PIXEL_BUDGET`]. At a high zoom the spread of a wide blur is tens of thousands of pixels a
/// side, whatever the size of the view (#1706). `None`: nothing to draw.
fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64, u16, u16, f64)> {
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
let spread = blur.max(0.0) * 1.5 + 2.0;
let screen = Rect::new(0.0, 0.0, f64::from(screen.0), f64::from(screen.1)).inflate(spread, spread);
let r = reach.inflate(spread, spread).intersect(screen);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (rw, rh) = (r.x1.ceil() - x0, r.y1.ceil() - y0);
// (Also false for a reach or a blur that isn't a number.)
if !(rw >= 1.0 && rh >= 1.0 && rw.is_finite() && rh.is_finite()) {
return None;
}
let side = f64::from(u16::MAX);
let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0);
let (w, h) = ((rw * k).floor().clamp(1.0, side), (rh * k).floor().clamp(1.0, side));
Some((x0, y0, w as u16, h as u16, k))
}
/// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel.
fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>, (x, y): (f64, f64), k: f64) {
let (w, h) = (image.width() as f64, image.height() as f64);
@@ -509,7 +532,8 @@ impl Drawn {
/// objects in it ([`Reads::of`]): which parts of [`Inputs`] can change what its rasters show.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Reads {
/// Pattern paints: the pattern definitions.
/// Pattern paints: the pattern definitions. Their art can hold anything, so content painted
/// with a pattern reads everything else too.
patterns: bool,
/// Symbol instances and type with inline graphics: the symbols' art. Symbol art can hold
/// anything, so content that shows a symbol reads everything else too.
@@ -527,14 +551,25 @@ pub(crate) struct Reads {
impl Reads {
/// What the content of `n` reads.
pub(crate) fn of(n: &Node) -> Self {
Self::of_all(std::iter::once(n))
}
/// What the content of `nodes` reads together. A pattern's art can hold anything, as a
/// symbol's can, so content painted with a pattern reads everything else too (#1723).
pub(crate) fn of_all<'a>(nodes: impl Iterator<Item = &'a Node>) -> Self {
let mut reads = Self::default();
reads.add(n);
if reads.symbols {
nodes.for_each(|n| reads.add(n));
if reads.symbols || reads.patterns {
return Self { patterns: true, symbols: true, images: true, brushes: true, text: true };
}
reads
}
/// Does the content read anything outside its own nodes?
pub(crate) fn any(self) -> bool {
self != Self::default()
}
fn add(&mut self, n: &Node) {
let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. });
n.walk(&mut |c| {
@@ -1365,18 +1400,9 @@ impl Renderer {
if !f.mt {
return self.inside_layer(|r| draw(r, ctx, f, composite));
}
// 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing.
let spread = blur.max(0.0) * 1.5 / f.px + 2.0;
let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread);
let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen);
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let (w, h) = ((r.x1.ceil() - x0).min(u16::MAX as f64), (r.y1.ceil() - y0).min(u16::MAX as f64));
if w < 1.0 || h < 1.0 {
return;
}
let (w, h) = (w as u16, h as u16);
let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return };
let mut off = f.offscreen_context(w, h);
let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f };
let shifted = Frame { mt: false, view: Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view, px: f.px / k, ..*f };
self.inside_layer(|r| draw(r, &mut off, &shifted, None));
off.flush();
let mut pm = vello_cpu::Pixmap::new(w, h);
@@ -1386,7 +1412,12 @@ impl Renderer {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None);
}
crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0));
if k < 1.0 {
draw_scaled(ctx, std::sync::Arc::new(pm), (x0, y0), k);
ctx.set_transform(Affine::IDENTITY);
} else {
crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0));
}
if layered {
ctx.pop_layer();
}
@@ -1482,9 +1513,28 @@ mod tests {
use vectorcraft_doc::{Appearance, Document, Effect, Node};
use vectorcraft_geom::{Affine, Rect, shapes};
use super::Drawn;
use super::{Drawn, PIXEL_BUDGET, filter_region};
use crate::{RenderOptions, Rendered, Renderer};
/// #1706: the raster a shadow or glow is filtered in stays within the pixel budget however
/// far the blur spreads at the zoom (it was up to 65535 pixels a side: 16 GB).
#[test]
fn a_wide_blur_at_a_high_zoom_is_filtered_within_the_pixel_budget() {
// A 100 pt square at 3200 % and at 6400 %, blurred by 1000 pt, in a 1600 × 1000 view.
for zoom in [32.0, 64.0] {
let reach = Rect::new(0.0, 0.0, 100.0 * zoom, 100.0 * zoom);
let (x0, y0, w, h, k) = filter_region(reach, (1600, 1000), 1000.0 * zoom).unwrap();
assert!(f64::from(w) * f64::from(h) <= PIXEL_BUDGET, "{w} × {h}");
assert!(k < 1.0 && x0 < 0.0 && y0 < 0.0);
// It still covers the view and the blur's spread around it, at that scale.
assert!(f64::from(w) / k >= 1600.0 + 1500.0 * zoom && f64::from(h) / k >= 1000.0 + 1500.0 * zoom);
}
// A small blur is filtered at full size, cropped to what can show.
assert_eq!(filter_region(Rect::new(10.0, 20.0, 110.0, 70.0), (1600, 1000), 4.0), Some((2.0, 12.0, 116, 66, 1.0)));
assert_eq!(filter_region(Rect::new(5000.0, 0.0, 5100.0, 100.0), (1600, 1000), 4.0), None, "off the view");
assert_eq!(filter_region(Rect::new(0.0, 0.0, 100.0, 100.0), (1600, 1000), f64::NAN).map(|r| r.4), Some(1.0));
}
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
+6 -6
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>,
@@ -1088,8 +1089,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 +1105,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 +1163,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);
+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)
}
+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;
}
+43
View File
@@ -940,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
+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(
+264
View File
@@ -0,0 +1,264 @@
//! 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
/// 1 MB stack.
pub const MAX_ELEMENT_DEPTH: usize = if cfg!(target_arch = "wasm32") { 48 } else { 256 };
/// 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.
///
+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();
+32 -1
View File
@@ -24,6 +24,8 @@
//! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through
//! the host; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless)
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::mpsc::Sender;
use serde_json::{Value, json};
@@ -63,12 +65,41 @@ pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<ControlResponse>,
state: Arc<AtomicU8>,
}
/// A request's state: waiting to run, taken by the app ([`ControlRequest::claim`]), or given up
/// by its caller before that ([`Ticket::give_up`]). It leaves `QUEUED` once, one way or the other.
const QUEUED: u8 = 0;
const STARTED: u8 = 1;
const GIVEN_UP: u8 = 2;
/// The caller's side of a queued [`ControlRequest`].
pub struct Ticket(Arc<AtomicU8>);
impl Ticket {
/// Stop waiting for the request → `true` when it had not started, and so never runs: a caller
/// told "timeout" can then retry without the edit being applied twice, or applied later when
/// nobody expects it (#1725). `false`: the app is running it (or has); its outcome is unknown.
pub fn give_up(&self) -> bool {
self.0.compare_exchange(QUEUED, GIVEN_UP, Ordering::SeqCst, Ordering::SeqCst).is_ok()
}
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx }, rx)
(Self { method: method.into(), params, reply: tx, state: Arc::new(AtomicU8::new(QUEUED)) }, rx)
}
/// The caller's handle on this request, to give it up with.
pub fn ticket(&self) -> Ticket {
Ticket(self.state.clone())
}
/// Take the request to run it → `false` when its caller gave it up meanwhile (don't run it).
pub fn claim(&self) -> bool {
self.state.compare_exchange(QUEUED, STARTED, Ordering::SeqCst, Ordering::SeqCst).is_ok()
}
}
+19 -2
View File
@@ -41,10 +41,27 @@ pub const MESSAGE: &str = "message";
pub(super) const MESSAGE_SPEC: DialogSpec = DialogSpec { ok: None, ..SPEC };
/// Most messages kept for later while a dialog is open ([`tell`]).
const MAX_WAITING: usize = 16;
/// Tell the user `message` (with an optional `detail` line), both in the UI language, in a dialog
/// they close.
/// they close. While another dialog is open (the message comes from work in the background) it
/// waits its turn instead of replacing it: neither what the user was doing there nor the message
/// is lost.
pub fn tell(app: &mut VectorcraftApp, message: &str, detail: &str) {
app.ui.dialog = Some(Dialog::new(MESSAGE, json!({"message": message, "detail": detail})));
let d = Dialog::new(MESSAGE, json!({"message": message, "detail": detail}));
if app.ui.dialog.is_none() {
app.ui.dialog = Some(d);
} else if app.ui.told.len() < MAX_WAITING && !app.ui.dialog.iter().chain(&app.ui.told).any(|t| t.kind == MESSAGE && t.fields == d.fields) {
app.ui.told.push(d);
}
}
/// Show the next message that waited for a dialog to close (each frame).
pub(super) fn show_waiting(app: &mut VectorcraftApp) {
if app.ui.dialog.is_none() && !app.ui.told.is_empty() {
app.ui.dialog = Some(app.ui.told.remove(0));
}
}
/// Closes before running, so a dialog the command opens stays open.
+1
View File
@@ -366,6 +366,7 @@ pub(crate) fn settle(app: &mut VectorcraftApp) {
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
about::show(app, ctx);
settle(app);
confirm::show_waiting(app);
// The kind of dialog shown last frame: a different one (or none) means this one just opened, and
// its first field is to take the keyboard focus (`focus_id`, until a field takes it).
let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending"));
+4 -1
View File
@@ -268,7 +268,10 @@ mod tests {
let written = Written::default();
let w = written.clone();
let services = Services {
download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))),
download: Some(Box::new(move |name: &str, b: &[u8]| {
w.borrow_mut().push((name.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
(with_art(services), written)
+5 -2
View File
@@ -95,7 +95,10 @@ fn the_web_downloads_a_zip() {
let mut app = saved(&dir);
let got: Rc<RefCell<Vec<(String, usize)>>> = Rc::default();
let g = got.clone();
app.services.download = Some(Box::new(move |name: &str, b: &[u8]| g.borrow_mut().push((name.to_string(), b.len()))));
app.services.download = Some(Box::new(move |name: &str, b: &[u8]| {
g.borrow_mut().push((name.to_string(), b.len()));
Ok(())
}));
app.run("ui.packageDialog", json!({})).unwrap();
assert!(!frame(&mut app).contains("Location:"), "no folder to choose");
confirm(&mut app).unwrap();
@@ -124,7 +127,7 @@ fn fonts_left_out_of_the_package_are_named() {
assert!(detail.contains(gone) && !detail.contains("Source Sans 3"), "{detail}");
// The web's download says so too.
app.ui.dialog = None;
app.services.download = Some(Box::new(|_: &str, _: &[u8]| {}));
app.services.download = Some(Box::new(|_: &str, _: &[u8]| Ok(())));
app.run("ui.packageDialog", json!({})).unwrap();
confirm(&mut app).unwrap();
assert!(app.ui.status.contains(gone), "{}", app.ui.status);
@@ -24,7 +24,10 @@ fn app(dir: &std::path::Path, web: bool) -> (VectorcraftApp, Log) {
..Default::default()
};
if web {
services.download = Some(Box::new(move |name: &str, b: &[u8]| l2.borrow_mut().push(format!("download {name} {}", b.len()))));
services.download = Some(Box::new(move |name: &str, b: &[u8]| {
l2.borrow_mut().push(format!("download {name} {}", b.len()));
Ok(())
}));
}
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 60, "height": 40})).unwrap();
@@ -21,7 +21,10 @@ fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) {
let l = log.clone();
let mut services = Services::default();
if web {
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
l.borrow_mut().push((name.to_string(), bytes.to_vec()));
Ok(())
}));
} else {
services.reveal = Some(Box::new(move |path: &str| {
l.borrow_mut().push((path.to_string(), vec![]));
+4 -1
View File
@@ -21,7 +21,10 @@ fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) {
let l = log.clone();
let mut services = Services::default();
if web {
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
l.borrow_mut().push((name.to_string(), bytes.to_vec()));
Ok(())
}));
} else {
services.reveal = Some(Box::new(move |path: &str| {
l.borrow_mut().push((path.to_string(), vec![]));
+242 -43
View File
@@ -24,11 +24,11 @@ use crate::state::{ToolbarCategory, ToolbarEdits};
use crate::theme::Tokens;
use crate::{VectorcraftApp, icons, widgets};
/// A tool's cell in the drawer: its icon and name.
const CELL: egui::Vec2 = vec2(176.0, 28.0);
const ICON: f32 = 20.0;
/// The drawer's width: two cells, the gap between them and room for the scroll bar.
const WIDTH: f32 = 2.0 * CELL.x + 24.0;
/// A tool's row in the drawer: its icon, name and shortcut.
const ROW: f32 = 26.0;
const ICON: f32 = 18.0;
/// The drawer's width: one column of rows, framed in groups, and room for the scroll bar.
const WIDTH: f32 = 268.0;
/// Gap between the toolbar and the drawer, and the drawer's least distance from the window's edges.
const GAP: f32 = 4.0;
/// Size of the tool following the pointer while it is dragged.
@@ -244,6 +244,19 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Ok(())
})
})(),
"toolbar.show" => (|| {
let on = crate::menus::opt_bool(p, "on")?;
if let Some(name) = p.get("control").filter(|v| !v.is_null()) {
let name = name.as_str().ok_or("control must be fillStroke, coloring, drawMode or screenMode")?;
let flag = app
.ui
.toolbar_show
.get_mut(name)
.ok_or_else(|| format!("unknown control `{name}`: fillStroke, coloring, drawMode or screenMode"))?;
*flag = on.unwrap_or(!*flag);
}
Ok(app.ui.toolbar_show.json())
})(),
"toolbar.reset" => {
*current(&mut app.ui.toolbar_edits, app.ui.toolbar_advanced) = None;
Ok(slots_json(app))
@@ -362,6 +375,30 @@ fn sections() -> &'static [(&'static str, Vec<&'static ToolInfo>)] {
})
}
/// A heading of the drawer and the flyout groups listed under it.
type Heading = (&'static str, Vec<&'static [ToolInfo]>);
/// The headings in order, each with the flyout groups listed under it (a group under the heading
/// most of its tools have, the first tool's on a tie), each in its tools' order: what the drawer
/// frames together.
fn groups() -> &'static [Heading] {
static GROUPS: OnceLock<Vec<Heading>> = OnceLock::new();
GROUPS.get_or_init(|| {
let mut out: Vec<Heading> = sections().iter().map(|(cat, _)| (*cat, vec![])).collect();
for g in TOOL_GROUPS {
let Some(first) = g.first() else { continue };
let count = |h: &str| g.iter().filter(|t| heading(t.id) == h).count();
let h = g.iter().map(|t| heading(t.id)).fold(heading(first.id), |best, h| if count(h) > count(best) { h } else { best });
match out.iter_mut().find(|(c, _)| *c == h) {
Some((_, gs)) => gs.push(g),
None => out.push((h, vec![g])),
}
}
out.retain(|(_, gs)| !gs.is_empty());
out
})
}
/// The All Tools drawer beside the toolbar, its top by the ••• button (higher when that would put
/// it past the window's bottom), while `tools_drawer` is on.
pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
@@ -374,7 +411,10 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
let id = drawer_id();
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(WIDTH, 200.0), |r| r.size());
let x = (bar.right() + GAP).min(screen.right() - size.x - GAP).max(screen.left());
let y = button.top().min(screen.bottom() - size.y - GAP).max(screen.top() + GAP);
// Never above the toolbar's top: higher up are the window's title bar (and on macOS its
// buttons) and the bars under it.
let top = bar.top().max(screen.top() + GAP);
let y = button.top().min(screen.bottom() - size.y - GAP).max(top);
let in_toolbar: BTreeSet<&str> = crate::toolbar::slots(app).into_iter().flat_map(|(_, s)| s).collect();
let active = app.session.tool_id();
let dragged = DragAndDrop::payload::<ToolDrag>(ctx).map(|d| *d);
@@ -383,29 +423,69 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
let removing = dragged.is_some_and(|d| d.from_toolbar) && ctx.memory(|m| m.area_rect(id)).is_some_and(over);
let mut picked = None;
let mut reset = false;
let mut advanced = None;
let mut show_toggle = None;
let resp = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| {
crate::toolbar::flyout_frame(&t, removing).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
ui.set_width(WIDTH.min(screen.width() - 2.0 * GAP - 16.0).max(CELL.x));
let width = WIDTH.min(screen.width() - 2.0 * GAP - 16.0).max(160.0);
ui.set_width(width);
// All Tools, and the drawer's flyout menu: Reset, and the Basic or Advanced toolbar.
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("All Tools")).size(13.0).color(t.text_strong));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| reset = widgets::flat_button(ui, "Reset", 64.0).clicked());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (r, menu) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
icons::paint(ui, "menu", r.shrink(1.0), if menu.hovered() { t.text_strong } else { t.text_dim });
menu.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Drawer menu")));
let menu = menu.on_hover_text(tl!("Drawer menu"));
egui::Popup::menu(&menu).id(menu_id()).show(|ui| {
ui.set_min_width(160.0);
if widgets::menu_item(ui, "Reset", true, false) {
reset = true;
}
ui.separator();
for (label, adv) in [("Basic", false), ("Advanced", true)] {
if widgets::menu_item(ui, label, true, app.ui.toolbar_advanced == adv) {
advanced = Some(adv);
}
}
});
});
});
ui.add_space(4.0);
// As tall as its tools, up to the window's height (it scrolls then); the Area's size
// from the last frame mustn't cap it.
let room = (screen.height() - 2.0 * GAP - 68.0).max(120.0);
let room = (screen.bottom() - top - GAP - 104.0).max(120.0);
egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).auto_shrink([false, true]).show(ui, |ui| {
for (cat, tools) in sections() {
widgets::subheader(ui, tl!(cat));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
for tool in tools {
if cell(ui, &t, tool, tool.id == active, in_toolbar.contains(tool.id)) {
picked = Some(tool.id);
}
}
});
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
let inner = ui.available_width() - 10.0;
for (cat, gs) in groups() {
ui.add_space(6.0);
ui.label(egui::RichText::new(format!("{}:", tl!(cat))).size(12.0).color(t.text_strong));
ui.add_space(4.0);
for g in gs {
// A flyout group: its tools framed together, as one toolbar button holds them.
egui::Frame::NONE.stroke(Stroke::new(1.0, t.divider)).corner_radius(2.0).show(ui, |ui| {
ui.set_width(inner);
for tool in g.iter() {
if row(ui, &t, tool, tool.id == active, in_toolbar.contains(tool.id)) {
picked = Some(tool.id);
}
}
});
ui.add_space(5.0);
}
}
});
// Show: the controls under the toolbar's tools.
ui.add_space(4.0);
ui.separator();
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Show:")).size(12.0).color(t.text));
ui.add_space(4.0);
for (name, label, icon) in crate::state::ToolbarShow::CONTROLS {
if show_button(ui, &t, name, label, icon, app.ui.toolbar_show.get(name)) {
show_toggle = Some(name);
}
}
});
});
@@ -423,10 +503,21 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
if reset && let Err(e) = app.run("toolbar.reset", json!({})) {
app.ui.status = e;
}
// Basic or Advanced: the menu item of the other toolbar switches to it.
if advanced.is_some_and(|a| a != app.ui.toolbar_advanced)
&& let Err(e) = app.run("window.toolbarAdvanced", json!({}))
{
app.ui.status = e;
}
if let Some(name) = show_toggle
&& let Err(e) = app.run("toolbar.show", json!({ "control": name }))
{
app.ui.status = e;
}
if let Some(id) = picked {
app.select_tool(id);
app.ui.tools_drawer = false;
} else if resp.response.clicked_elsewhere() && !pointer.is_some_and(|p| bar.contains(p)) {
} else if resp.response.clicked_elsewhere() && !pointer.is_some_and(|p| bar.contains(p)) && !egui::Popup::is_id_open(ctx, menu_id()) {
// A click in the toolbar (picking a tool, or the ••• button that toggles the drawer) leaves
// it open.
app.ui.tools_drawer = false;
@@ -434,19 +525,68 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
ghost(ctx, &t);
}
/// A tool's cell in the drawer: icon and name, dimmed when the tool is in the toolbar already (it
/// can't be dragged in again). Returns whether it was clicked.
fn cell(ui: &mut Ui, t: &Tokens, tool: &'static ToolInfo, on: bool, dimmed: bool) -> bool {
let (rect, resp) = ui.allocate_exact_size(CELL, if dimmed { Sense::click() } else { Sense::click_and_drag() });
/// A Show section toggle: lit while its control shows under the toolbar's tools. Fill and Stroke
/// and Coloring are drawn as small copies of those controls, the modes with their buttons' icons.
fn show_button(ui: &mut Ui, t: &Tokens, name: &str, label: &str, icon: &str, on: bool) -> bool {
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, tl!(label)));
let p = ui.painter();
if on {
p.rect_filled(r, 3.0, t.tool_active);
} else if resp.hovered() {
p.rect_filled(r, 3.0, t.hover);
}
let ink = if on { t.text_strong } else { t.icon };
let c = r.center();
match name {
"fillStroke" => {
// The proxy: a filled square in front of a stroked one.
let back = Rect::from_center_size(c + vec2(3.0, 3.0), vec2(10.0, 10.0));
p.rect_stroke(back, 0.0, Stroke::new(2.0, ink), StrokeKind::Inside);
p.rect_filled(Rect::from_center_size(c - vec2(3.0, 3.0), vec2(10.0, 10.0)), 0.0, ink);
}
"coloring" => {
// Color, Gradient and None.
for (k, x) in [-6.5f32, 0.0, 6.5].into_iter().enumerate() {
let chip = Rect::from_center_size(c + vec2(x, 0.0), vec2(5.5, 9.0));
match k {
0 => {
p.rect_filled(chip, 0.0, ink);
}
1 => {
p.rect_filled(chip, 0.0, ink.gamma_multiply(0.5));
}
_ => {
p.rect_stroke(chip, 0.0, Stroke::new(1.0, ink), StrokeKind::Inside);
p.line_segment([chip.left_bottom(), chip.right_top()], Stroke::new(1.0, ink));
}
}
}
}
_ => icons::paint(ui, icon, r.shrink(5.0), ink),
}
resp.on_hover_text(tl!(label)).clicked()
}
/// The drawer's flyout menu.
fn menu_id() -> egui::Id {
egui::Id::new("tools-drawer-menu")
}
/// A tool's row in the drawer: icon, name and shortcut, dimmed when the tool is in the toolbar
/// already (it can't be dragged in again). Returns whether it was clicked.
fn row(ui: &mut Ui, t: &Tokens, tool: &'static ToolInfo, on: bool, dimmed: bool) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), if dimmed { Sense::click() } else { Sense::click_and_drag() });
if !dimmed {
resp.dnd_set_drag_payload(ToolDrag { tool: tool.id, from_toolbar: false });
}
let fill = rect.shrink(1.0);
if on {
ui.painter().rect_filled(rect, 3.0, t.selection);
ui.painter().rect_filled(fill, 2.0, t.selection);
} else if resp.hovered() {
ui.painter().rect_filled(rect, 3.0, t.hover);
ui.painter().rect_filled(fill, 2.0, t.hover);
}
// The active tool's cell is lit, dimmed or not.
// The active tool's row is lit, dimmed or not.
let color = if on {
t.text_strong
} else if dimmed {
@@ -454,12 +594,31 @@ fn cell(ui: &mut Ui, t: &Tokens, tool: &'static ToolInfo, on: bool, dimmed: bool
} else {
t.text
};
let icon = Rect::from_center_size(rect.left_center() + vec2(6.0 + ICON / 2.0, 0.0), vec2(ICON, ICON));
let icon = Rect::from_center_size(rect.left_center() + vec2(8.0 + ICON / 2.0, 0.0), vec2(ICON, ICON));
icons::paint(ui, icons::tool_icon(tool.icon), icon, if dimmed || on { color } else { t.icon });
let name = tl!(tool.label);
let name = if crate::i18n::current() == crate::i18n::Lang::EN { name.trim_end_matches(" Tool") } else { name };
let text_at = pos2(icon.right() + 8.0, rect.center().y);
ui.painter().with_clip_rect(rect).text(text_at, egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), color);
let painter = ui.painter().with_clip_rect(rect);
let shortcut = crate::shortcut_editor::tool_shortcut(tool.id);
let keys_w = shortcut.as_ref().map_or(0.0, |k| {
let keys = if on {
t.text_strong
} else if dimmed {
t.text_disabled
} else {
t.text_dim
};
let g = painter.layout_no_wrap(k.to_string(), egui::FontId::proportional(11.5), keys);
let w = g.size().x;
painter.galley(pos2(rect.right() - 10.0 - w, rect.center().y - g.size().y / 2.0), g, keys);
w + 12.0
});
let text_at = pos2(icon.right() + 10.0, rect.center().y);
painter.with_clip_rect(Rect::from_min_max(rect.min, pos2(rect.right() - 10.0 - keys_w, rect.bottom()))).text(
text_at,
egui::Align2::LEFT_CENTER,
tl!(tool.label),
egui::FontId::proportional(12.5),
color,
);
resp.on_hover_text(crate::toolbar::tip(tool)).clicked()
}
@@ -483,7 +642,7 @@ mod tests {
use super::*;
/// Tall enough for the whole Basic toolbar and the whole drawer.
const SCREEN: egui::Vec2 = vec2(1000.0, 2400.0);
const SCREEN: egui::Vec2 = vec2(1000.0, 4000.0);
/// A toolbar tool button.
const BUTTON: egui::Vec2 = vec2(36.0, 29.0);
@@ -561,7 +720,7 @@ mod tests {
ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Foreground, drawer_id())))
}
/// A point inside the drawer's cell of the tool named `name` (as the drawer shows it), and the
/// A point inside the drawer's row of the tool named `name` (as the drawer shows it), and the
/// colour of its name.
fn cell_at(shown: &[(String, Pos2, Color32)], drawer: Rect, name: &str) -> (Pos2, Color32) {
shown
@@ -571,6 +730,40 @@ mod tests {
.unwrap_or_else(|| panic!("no {name} in the drawer {drawer:?}"))
}
/// The drawer frames each flyout group whole, under the heading most of its tools have: every
/// tool once, the Pencil's group (led by the Shaper) under Draw.
#[test]
fn the_drawer_frames_every_flyout_group_once() {
let listed: Vec<&str> = groups().iter().flat_map(|(_, gs)| gs.iter().flat_map(|g| g.iter().map(|t| t.id))).collect();
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
assert_eq!(listed.len(), all.len());
assert!(all.iter().all(|id| listed.contains(id)));
let under = |id: &str| groups().iter().find(|(_, gs)| gs.iter().any(|g| g.iter().any(|t| t.id == id))).map(|(c, _)| *c);
assert_eq!(under("pencil"), Some("Draw"));
assert_eq!(under("shaper"), Some("Draw"), "framed with the Pencil");
assert_eq!(under("rectangle"), Some("Shapes"));
}
/// The Show section's controls (#1736): each shows (true), hides (false) or toggles, saved with
/// the preferences; an unknown control is an error naming them.
#[test]
fn toolbar_show_turns_the_controls_under_the_tools_on_and_off() {
let mut app = app();
let all_on = json!({"fillStroke": true, "coloring": true, "drawMode": true, "screenMode": true});
assert_eq!(app.run("toolbar.show", json!({})).unwrap(), all_on);
assert_eq!(app.run("toolbar.show", json!({"control": "coloring"})).unwrap()["coloring"], json!(false));
assert_eq!(app.run("toolbar.show", json!({"control": "screenMode", "on": false})).unwrap()["screenMode"], json!(false));
assert!(!app.ui.toolbar_show.coloring && !app.ui.toolbar_show.screen_mode && app.ui.toolbar_show.fill_stroke);
let err = app.run("toolbar.show", json!({"control": "swatches"})).unwrap_err();
assert!(err.contains("swatches") && err.contains("fillStroke"), "{err}");
// Saved with the preferences; older preferences show them all.
let saved = serde_json::to_value(&app.ui).unwrap();
assert_eq!(serde_json::from_value::<crate::state::UiState>(saved).unwrap().toolbar_show, app.ui.toolbar_show);
let mut old = serde_json::to_value(&app.ui).unwrap();
old.as_object_mut().unwrap().remove("toolbar_show");
assert_eq!(serde_json::from_value::<crate::state::UiState>(old).unwrap().toolbar_show, crate::state::ToolbarShow::default());
}
/// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order.
#[test]
fn every_tool_is_listed_once_under_a_heading() {
@@ -698,8 +891,9 @@ mod tests {
let bar = ctx.data(|d| d.get_temp::<Rect>(toolbar_id())).unwrap();
assert!((drawer.left() - bar.right() - GAP).abs() < 1.0, "beside the toolbar: {drawer:?} {bar:?}");
assert!((drawer.top() - edit.top()).abs() < 1.0 || drawer.bottom() >= SCREEN.y - GAP - 1.0, "by the button: {drawer:?} {edit:?}");
assert!(drawer.top() >= bar.top() - 0.5, "never above the toolbar (the title bar's there): {drawer:?} {bar:?}");
assert!(drawer.y_range().contains(edit.center().y), "by the button: {drawer:?} {edit:?}");
assert!(drawer.width() < 3.0 * CELL.x, "two tools wide: {drawer:?}");
assert!(drawer.width() < WIDTH + 24.0, "one row of tools wide: {drawer:?}");
// A click on a toolbar tool picks it and leaves the drawer open.
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
let ellipse = tools[slot_of(&app, "ellipse").unwrap()].center();
@@ -724,7 +918,7 @@ mod tests {
app.run("window.editToolbar", json!({})).unwrap();
frame(&mut app, &ctx, 7.0, vec![]);
let shown = frame(&mut app, &ctx, 7.1, vec![]);
let (at, _) = cell_at(&shown, drawer_rect(&ctx).unwrap(), "Artboard");
let (at, _) = cell_at(&shown, drawer_rect(&ctx).unwrap(), "Artboard Tool");
click(&mut app, &ctx, 8.0, at);
assert!(!app.ui.tools_drawer && app.session.tool_id() == "artboard");
}
@@ -739,13 +933,13 @@ mod tests {
let shown = frame(&mut app, &ctx, 0.1, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let dim = Tokens::get(&ctx).text_disabled;
assert_eq!(cell_at(&shown, drawer, "Ellipse").1, dim);
assert_ne!(cell_at(&shown, drawer, "Artboard").1, dim);
assert_eq!(cell_at(&shown, drawer, "Ellipse Tool").1, dim);
assert_ne!(cell_at(&shown, drawer, "Artboard Tool").1, dim);
app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap();
let shown = frame(&mut app, &ctx, 0.2, vec![]);
assert_ne!(cell_at(&shown, drawer, "Ellipse").1, dim);
assert_eq!(cell_at(&shown, drawer, "Artboard").1, dim);
assert_ne!(cell_at(&shown, drawer, "Ellipse Tool").1, dim);
assert_eq!(cell_at(&shown, drawer, "Artboard Tool").1, dim);
}
/// #1018: tools drag from the drawer onto the toolbar (between two tools, or onto one to join
@@ -761,19 +955,24 @@ mod tests {
let drawer = drawer_rect(&ctx).unwrap();
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
// Onto the divider under the Selection tool: its own slot, second.
let (artboard, _) = cell_at(&shown, drawer, "Artboard");
let (artboard, _) = cell_at(&shown, drawer, "Artboard Tool");
drag(&mut app, &ctx, 1.0, artboard, tools[0].center_bottom() - vec2(0.0, 3.0));
assert_eq!(&slots(&app)[..2], [&["selection"][..], &["artboard"]]);
assert!(app.ui.tools_drawer, "the drawer stays open");
// Onto the middle of the Ellipse tool: into its group.
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
let ellipse = slot_of(&app, "ellipse").unwrap();
let (slice, _) = cell_at(&shown, drawer, "Slice");
// The drawer moves with the ••• button the new slot pushed down: read it again.
let shown = frame(&mut app, &ctx, 1.9, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let (slice, _) = cell_at(&shown, drawer, "Slice Tool");
drag(&mut app, &ctx, 2.0, slice, tools[ellipse].center());
assert_eq!(slots(&app)[ellipse], ["ellipse", "slice"]);
// A dimmed tool (in the toolbar already) doesn't drag.
let before = slots(&app);
let (rectangle, _) = cell_at(&shown, drawer, "Rectangle");
let shown = frame(&mut app, &ctx, 2.9, vec![]);
let drawer = drawer_rect(&ctx).unwrap();
let (rectangle, _) = cell_at(&shown, drawer, "Rectangle Tool");
drag(&mut app, &ctx, 3.0, rectangle, tools[0].center_bottom() - vec2(0.0, 3.0));
assert_eq!(slots(&app), before);
// From the toolbar into the drawer: out of the toolbar.
+12
View File
@@ -3508,6 +3508,8 @@ effect Regular Pravidelný
Serif / Mincho Patkové / mincho
Changes made since it was last saved will be lost. Změny provedené od posledního uložení budou ztraceny.
Can't open “{name}”. „{name}“ nelze otevřít.
Can't save “{name}”. „{name}“ nelze uložit.
Can't export “{name}”. „{name}“ nelze exportovat.
Downloaded {what} Staženo: {what}
Exported {count} file(s) to {folder} Exportováno souborů: {count} do {folder}
Exported {what} Exportováno: {what}
@@ -4099,6 +4101,8 @@ imageTrace Outline Obrys
@msg recovery copy `{file}`: {e} kopie pro obnovení `{file}`: {e}
@msg resolutions must be between 1 and 2400 ppi rozlišení musí být mezi 1 a 2400 ppi
@msg result would exceed the canvas (coordinates out of range) výsledek by přesáhl plátno (souřadnice mimo rozsah)
@msg objects can't be nested more than 32 deep objekty nelze vnořit hlouběji než do 32 úrovní
@msg the SVG's elements are nested more than {0} deep prvky SVG jsou vnořeny hlouběji než do {0} úrovní
@msg scale must be a number from {_1} to {_2}, not {_3} měřítko musí být číslo od {_1} do {_2}, ne {_3}
@msg scale must be a number from {_1} to {_2}, not {s} měřítko musí být číslo od {_1} do {_2}, ne {s}
@msg select (or target) objects whose appearance isn't basic vyberte (nebo zacilte) objekty, jejichž vzhled není základní
@@ -4745,6 +4749,14 @@ mark-style Japanese Japonské
@msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys}
Load EPS into Clipboard Načíst EPS do schránky
@msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení
Fill and Stroke Controls Ovládací prvky výplně a tahu
Coloring Controls Ovládací prvky barev
Draw Mode Controls Ovládací prvky režimu kreslení
Screen Mode Controls Ovládací prvky režimu zobrazení
Drawer menu Nabídka zásuvky
Toolbar: Show Controls Panel nástrojů: Zobrazit ovládací prvky
@msg control must be fillStroke, coloring, drawMode or screenMode control musí být fillStroke, coloring, drawMode nebo screenMode
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode neznámý ovládací prvek `{name}`: fillStroke, coloring, drawMode nebo screenMode
@msg give a fraction `t` from 0 to 1 zadejte hodnotu `t` od 0 do 1
@msg give the path `id` zadejte `id` cesty
@msg invalid stroke widths neplatné šířky tahu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -754,6 +754,8 @@
Do Not Move Nicht verschieben
Do you want to save the changes you made to “{name}”? Möchten Sie die Änderungen an „{name}“ speichern?
Can't open “{name}”. „{name}“ kann nicht geöffnet werden.
Can't save “{name}”. „{name}“ kann nicht gespeichert werden.
Can't export “{name}”. „{name}“ kann nicht exportiert werden.
Document Dokument
Document Color Mode Dokumentfarbmodus
Document Info Dokumentinformationen
@@ -3705,6 +3707,8 @@ theme Appearance Erscheinungsbild
@msg recovery copy `{file}`: {e} Wiederherstellungskopie `{file}`: {e}
@msg resolutions must be between 1 and 2400 ppi Auflösungen müssen zwischen 1 und 2400 ppi liegen
@msg result would exceed the canvas (coordinates out of range) das Ergebnis würde die Arbeitsfläche überschreiten (Koordinaten außerhalb des Bereichs)
@msg objects can't be nested more than 32 deep Objekte können nicht tiefer als 32 Ebenen verschachtelt werden
@msg the SVG's elements are nested more than {0} deep die Elemente der SVG-Datei sind tiefer als {0} Ebenen verschachtelt
@msg scale must be a number from {_1} to {_2}, not {_3} scale muss eine Zahl von {_1} bis {_2} sein, nicht {_3}
@msg scale must be a number from {_1} to {_2}, not {s} scale muss eine Zahl von {_1} bis {_2} sein, nicht {s}
@msg select (or target) objects whose appearance isn't basic wählen Sie Objekte aus (oder als Ziel), deren Aussehen nicht einfach ist
@@ -4269,6 +4273,15 @@ theme Appearance Erscheinungsbild
@msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys}
Load EPS into Clipboard EPS in die Zwischenablage laden
@msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden
Basic Basis
Fill and Stroke Controls Steuerelemente für Fläche und Kontur
Coloring Controls Steuerelemente für Farbe
Draw Mode Controls Steuerelemente für Zeichenmodus
Screen Mode Controls Steuerelemente für Bildschirmmodus
Drawer menu Menü der Werkzeugschublade
Toolbar: Show Controls Werkzeugleiste: Steuerelemente einblenden
@msg control must be fillStroke, coloring, drawMode or screenMode control muss fillStroke, coloring, drawMode oder screenMode sein
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode unbekanntes Steuerelement `{name}`: fillStroke, coloring, drawMode oder screenMode
@msg give a fraction `t` from 0 to 1 geben Sie einen Wert `t` von 0 bis 1 an
@msg give the path `id` geben Sie die `id` des Pfads an
@msg invalid stroke widths ungültige Konturbreiten
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -768,6 +768,8 @@
Do Not Move No mover
Do you want to save the changes you made to “{name}”? ¿Quieres guardar los cambios hechos en “{name}”?
Can't open “{name}”. No se puede abrir «{name}».
Can't save “{name}”. No se puede guardar «{name}».
Can't export “{name}”. No se puede exportar «{name}».
Document Documento
Document Color Mode Modo de color del documento
Document Info Información del documento
@@ -3804,6 +3806,8 @@ axis Both Ambos
@msg recovery copy `{file}`: {e} copia de recuperación `{file}`: {e}
@msg resolutions must be between 1 and 2400 ppi las resoluciones deben estar entre 1 y 2400 ppi
@msg result would exceed the canvas (coordinates out of range) el resultado excedería el lienzo (coordenadas fuera de rango)
@msg objects can't be nested more than 32 deep los objetos no pueden anidarse a más de 32 niveles
@msg the SVG's elements are nested more than {0} deep los elementos del SVG están anidados a más de {0} niveles
@msg scale must be a number from {_1} to {_2}, not {_3} la escala debe ser un número de {_1} a {_2}, no {_3}
@msg scale must be a number from {_1} to {_2}, not {s} la escala debe ser un número de {_1} a {_2}, no {s}
@msg select (or target) objects whose appearance isn't basic selecciona (o apunta a) objetos cuya apariencia no sea básica
@@ -4686,6 +4690,14 @@ mark-style Japanese Japonesas
@msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys}
Load EPS into Clipboard Cargar EPS en el portapapeles
@msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse
Fill and Stroke Controls Controles de relleno y trazo
Coloring Controls Controles de color
Draw Mode Controls Controles de modo de dibujo
Screen Mode Controls Controles de modo de pantalla
Drawer menu Menú del cajón
Toolbar: Show Controls Barra de herramientas: Mostrar controles
@msg control must be fillStroke, coloring, drawMode or screenMode control debe ser fillStroke, coloring, drawMode o screenMode
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode control desconocido `{name}`: fillStroke, coloring, drawMode o screenMode
@msg give a fraction `t` from 0 to 1 indica un valor `t` de 0 a 1
@msg give the path `id` indica el `id` del trazado
@msg invalid stroke widths anchuras de trazo no válidas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -770,6 +770,8 @@
Do Not Move Ne pas déplacer
Do you want to save the changes you made to “{name}”? Voulez-vous enregistrer les modifications apportées à « {name} » ?
Can't open “{name}”. Impossible d’ouvrir « {name} ».
Can't save “{name}”. Impossible d’enregistrer « {name} ».
Can't export “{name}”. Impossible d’exporter « {name} ».
Document Document
Document Color Mode Mode colorimétrique du document
Document Info Informations sur le document
@@ -3801,6 +3803,8 @@ axis Both Les deux
@msg recovery copy `{file}`: {e} copie de récupération `{file}` : {e}
@msg resolutions must be between 1 and 2400 ppi les résolutions doivent être comprises entre 1 et 2400 ppp
@msg result would exceed the canvas (coordinates out of range) le résultat dépasserait la zone de travail (coordonnées hors limites)
@msg objects can't be nested more than 32 deep les objets ne peuvent pas être imbriqués sur plus de 32 niveaux
@msg the SVG's elements are nested more than {0} deep les éléments du SVG sont imbriqués sur plus de {0} niveaux
@msg scale must be a number from {_1} to {_2}, not {_3} l’échelle doit être un nombre compris entre {_1} et {_2}, pas {_3}
@msg scale must be a number from {_1} to {_2}, not {s} l’échelle doit être un nombre compris entre {_1} et {_2}, pas {s}
@msg select (or target) objects whose appearance isn't basic sélectionnez (ou ciblez) des objets dont l’aspect n’est pas basique
@@ -4745,6 +4749,14 @@ mark-style Japanese Japonais
theme Appearance Apparence
Load EPS into Clipboard Charger un EPS dans le presse-papiers
@msg an image of {w} × {h} pixels is too large to draw une image de {w} × {h} pixels est trop grande pour être dessinée
Fill and Stroke Controls Commandes de fond et de contour
Coloring Controls Commandes de couleur
Draw Mode Controls Commandes de mode de dessin
Screen Mode Controls Commandes de mode d'affichage
Drawer menu Menu du tiroir
Toolbar: Show Controls Barre d’outils : Afficher les commandes
@msg control must be fillStroke, coloring, drawMode or screenMode control doit être fillStroke, coloring, drawMode ou screenMode
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode commande inconnue `{name}` : fillStroke, coloring, drawMode ou screenMode
@msg give a fraction `t` from 0 to 1 indiquez une valeur `t` comprise entre 0 et 1
@msg give the path `id` indiquez l’`id` du tracé
@msg invalid stroke widths largeurs de contour non valides
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -485,6 +485,8 @@
Calligraphic Kalligrafikus
Calligraphic Brush Options Kalligrafikus ecset beállításai
Can't open “{name}”. Nem nyitható meg: „{name}”.
Can't save “{name}”. Nem menthető: „{name}”.
Can't export “{name}”. Nem exportálható: „{name}”.
Can't print these settings: see the Summary Ezekkel a beállításokkal nem lehet nyomtatni: lásd az Összegzést
Cancel Mégse
Cancel Key Object Kulcsobjektum megszüntetése
@@ -4441,6 +4443,8 @@ imageTrace Outline Körvonal
@msg resolutions must be between 1 and 2400 ppi a felbontás 1 és 2400 ppi között lehet
@msg Restored {_1} recovered document(s): save them to keep them Helyreállított dokumentumok: {_1}; mentsd őket, ha meg akarod tartani
@msg result would exceed the canvas (coordinates out of range) az eredmény túllépné a vásznat (a koordináták tartományon kívül esnek)
@msg objects can't be nested more than 32 deep az objektumok legfeljebb 32 szint mélyen ágyazhatók egymásba
@msg the SVG's elements are nested more than {0} deep az SVG elemei több mint {0} szint mélyen vannak egymásba ágyazva
@msg Save for Web: the image is empty Mentés webre: a kép üres
@msg Save writes {_1}, not {_2}: use document.export for other formats a Mentés ezt írja: {_1}, ezt nem: {_2}; más formátumhoz használd a document.export parancsot
@msg saved panel layout is invalid; reset the workspace first a mentett panelelrendezés érvénytelen; előbb állítsd vissza a munkaterületet
@@ -4712,6 +4716,14 @@ imageTrace Outline Körvonal
@msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys}
Load EPS into Clipboard EPS betöltése a vágólapra
@msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz
Fill and Stroke Controls Kitöltés és körvonal vezérlői
Coloring Controls Színezés vezérlői
Draw Mode Controls Rajzolási mód vezérlői
Screen Mode Controls Képernyőmód vezérlői
Drawer menu Fiók menüje
Toolbar: Show Controls Eszköztár: Vezérlők megjelenítése
@msg control must be fillStroke, coloring, drawMode or screenMode a control értéke fillStroke, coloring, drawMode vagy screenMode lehet
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode ismeretlen vezérlő: `{name}`; fillStroke, coloring, drawMode vagy screenMode
@msg give a fraction `t` from 0 to 1 add meg a `t` értékét 0 és 1 között
@msg give the path `id` add meg az útvonal `id` értékét
@msg invalid stroke widths érvénytelen körvonalszélességek
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -768,6 +768,8 @@
Do Not Move Non spostare
Do you want to save the changes you made to “{name}”? Vuoi salvare le modifiche apportate a “{name}”?
Can't open “{name}”. Impossibile aprire «{name}».
Can't save “{name}”. Impossibile salvare «{name}».
Can't export “{name}”. Impossibile esportare «{name}».
Document Documento
Document Color Mode Metodo colore documento
Document Info Informazioni documento
@@ -3804,6 +3806,8 @@ axis Both Entrambi
@msg recovery copy `{file}`: {e} copia di recupero `{file}`: {e}
@msg resolutions must be between 1 and 2400 ppi le risoluzioni devono essere comprese tra 1 e 2400 ppi
@msg result would exceed the canvas (coordinates out of range) il risultato supererebbe l'area di disegno (coordinate fuori intervallo)
@msg objects can't be nested more than 32 deep gli oggetti non possono essere nidificati oltre 32 livelli
@msg the SVG's elements are nested more than {0} deep gli elementi dell'SVG sono nidificati oltre {0} livelli
@msg scale must be a number from {_1} to {_2}, not {_3} la scala deve essere un numero da {_1} a {_2}, non {_3}
@msg scale must be a number from {_1} to {_2}, not {s} la scala deve essere un numero da {_1} a {_2}, non {s}
@msg select (or target) objects whose appearance isn't basic seleziona (o imposta come destinazione) oggetti con un aspetto non di base
@@ -4314,6 +4318,15 @@ lighting Light Luce
@msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys}
Load EPS into Clipboard Carica EPS negli appunti
@msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata
Basic Base
Fill and Stroke Controls Controlli di riempimento e traccia
Coloring Controls Controlli colore
Draw Mode Controls Controlli modalità di disegno
Screen Mode Controls Controlli modalità schermo
Drawer menu Menu del cassetto
Toolbar: Show Controls Barra degli strumenti: Mostra controlli
@msg control must be fillStroke, coloring, drawMode or screenMode control deve essere fillStroke, coloring, drawMode o screenMode
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode controllo sconosciuto `{name}`: fillStroke, coloring, drawMode o screenMode
@msg give a fraction `t` from 0 to 1 specifica un valore `t` da 0 a 1
@msg give the path `id` specifica l'`id` del tracciato
@msg invalid stroke widths larghezze della traccia non valide
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -1223,6 +1223,8 @@
Divide Vertically Into 垂直方向に分割
Do you want to save the changes you made to “{name}”? 「{name}」への変更を保存しますか?
Can't open “{name}”. 「{name}」を開けません。
Can't save “{name}”. 「{name}」を保存できません。
Can't export “{name}”. 「{name}」を書き出せません。
Document ドキュメント
Document Ink Options ドキュメントのインキオプション
Document JSON ドキュメント JSON
@@ -3730,6 +3732,8 @@ theme Appearance 外観
@msg no interaction in progress 進行中の操作がありません
@msg no such object {0} オブジェクト {0} が見つかりません
@msg result would exceed the canvas (coordinates out of range) 結果がカンバスの範囲を超えます (座標が範囲外です)
@msg objects can't be nested more than 32 deep オブジェクトは 32 階層より深く入れ子にできません
@msg the SVG's elements are nested more than {0} deep SVG の要素が {0} 階層より深く入れ子になっています
@msg unknown command `{0}` `{0}` は不明なコマンドです
@msg `{what}` closes nothing `{what}` に対応する開始部分がありません
@msg `{what}` closes something it didn't open `{what}` が対応しない開始部分を閉じています
@@ -4308,6 +4312,15 @@ lighting Light ライト
@msg unknown parameter `{key}`; the command takes: {keys} `{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys}
Load EPS into Clipboard EPS をクリップボードに読み込む
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません
Basic 基本
Fill and Stroke Controls 塗りと線のコントロール
Coloring Controls カラーのコントロール
Draw Mode Controls 描画モードのコントロール
Screen Mode Controls スクリーンモードのコントロール
Drawer menu ドロワーメニュー
Toolbar: Show Controls ツールバー: コントロールを表示
@msg control must be fillStroke, coloring, drawMode or screenMode control は fillStroke、coloring、drawMode、screenMode のいずれかです
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode 不明なコントロール `{name}`:fillStroke、coloring、drawMode、screenMode のいずれかです
@msg give a fraction `t` from 0 to 1 0 から 1 までの値 `t` を指定してください
@msg give the path `id` パスの `id` を指定してください
@msg invalid stroke widths 線幅が無効です
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -1218,6 +1218,8 @@
Divide Vertically Into 세로로 나누기
Do you want to save the changes you made to “{name}”? “{name}”의 변경 내용을 저장하시겠습니까?
Can't open “{name}”. “{name}”을(를) 열 수 없습니다.
Can't save “{name}”. “{name}”을(를) 저장할 수 없습니다.
Can't export “{name}”. “{name}”을(를) 내보낼 수 없습니다.
Document 문서
Document Ink Options 문서 잉크 옵션
Document JSON 문서 JSON
@@ -3708,6 +3710,8 @@ theme Appearance 외관
@msg no interaction in progress 진행 중인 상호작용이 없습니다
@msg no such object {0} 개체 {0}이(가) 없습니다
@msg result would exceed the canvas (coordinates out of range) 결과가 캔버스를 벗어납니다(좌표 범위 초과)
@msg objects can't be nested more than 32 deep 개체는 32단계보다 깊게 중첩할 수 없습니다
@msg the SVG's elements are nested more than {0} deep SVG 요소가 {0}단계보다 깊게 중첩되어 있습니다
@msg unknown command `{0}` 알 수 없는 명령 `{0}`
@msg `{what}` closes nothing `{what}`이(가) 닫는 항목이 없습니다
@msg `{what}` closes something it didn't open `{what}`이(가) 열지 않은 항목을 닫습니다
@@ -4278,6 +4282,15 @@ lighting Light 광원
@msg unknown parameter `{key}`; the command takes: {keys} 알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys}
Load EPS into Clipboard EPS를 클립보드로 불러오기
@msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다
Basic 기본
Fill and Stroke Controls 칠 및 획 컨트롤
Coloring Controls 색상 컨트롤
Draw Mode Controls 그리기 모드 컨트롤
Screen Mode Controls 화면 모드 컨트롤
Drawer menu 서랍 메뉴
Toolbar: Show Controls 도구 막대: 컨트롤 표시
@msg control must be fillStroke, coloring, drawMode or screenMode control은 fillStroke, coloring, drawMode, screenMode 중 하나여야 합니다
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode 알 수 없는 컨트롤 `{name}`: fillStroke, coloring, drawMode, screenMode 중 하나
@msg give a fraction `t` from 0 to 1 0에서 1 사이의 값 `t`를 지정하세요
@msg give the path `id` 패스 `id`를 지정하세요
@msg invalid stroke widths 선 너비가 유효하지 않습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -3391,6 +3391,8 @@ effect Regular Normalny
Serif / Mincho Szeryfowe / mincho
Changes made since it was last saved will be lost. Zmiany wprowadzone od ostatniego zapisu zostaną utracone.
Can't open “{name}”. Nie można otworzyć „{name}”.
Can't save “{name}”. Nie można zapisać „{name}”.
Can't export “{name}”. Nie można wyeksportować „{name}”.
Downloaded {what} Pobrano {what}
Exported {count} file(s) to {folder} Wyeksportowane pliki do {folder}: {count}
Exported {what} Wyeksportowano {what}
@@ -3965,6 +3967,8 @@ imageTrace Outline Kontur
@msg recovery copy `{file}`: {e} kopia odzyskiwania `{file}`: {e}
@msg resolutions must be between 1 and 2400 ppi rozdzielczości muszą mieścić się w zakresie od 1 do 2400 ppi
@msg result would exceed the canvas (coordinates out of range) wynik wykroczyłby poza płótno (współrzędne poza zakresem)
@msg objects can't be nested more than 32 deep obiektów nie można zagnieżdżać głębiej niż na 32 poziomy
@msg the SVG's elements are nested more than {0} deep elementy SVG są zagnieżdżone głębiej niż na {0} poziomów
@msg scale must be a number from {_1} to {_2}, not {_3} skala musi być liczbą od {_1} do {_2}, nie {_3}
@msg scale must be a number from {_1} to {_2}, not {s} skala musi być liczbą od {_1} do {_2}, nie {s}
@msg select (or target) objects whose appearance isn't basic zaznacz (lub wskaż) obiekty, których wygląd nie jest podstawowy
@@ -4587,6 +4591,15 @@ mark-style Japanese Japońskie
@msg unknown parameter `{key}`; the command takes: {keys} nieznany parametr `{key}`; polecenie przyjmuje: {keys}
Load EPS into Clipboard Wczytaj EPS do schowka
@msg an image of {w} × {h} pixels is too large to draw obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go narysować
Basic Podstawowy
Fill and Stroke Controls Elementy sterujące wypełnieniem i obrysem
Coloring Controls Elementy sterujące kolorem
Draw Mode Controls Elementy sterujące trybem rysowania
Screen Mode Controls Elementy sterujące trybem ekranu
Drawer menu Menu szuflady
Toolbar: Show Controls Pasek narzędzi: Pokaż elementy sterujące
@msg control must be fillStroke, coloring, drawMode or screenMode control musi mieć wartość fillStroke, coloring, drawMode lub screenMode
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode nieznany element sterujący `{name}`: fillStroke, coloring, drawMode lub screenMode
@msg give a fraction `t` from 0 to 1 podaj wartość `t` od 0 do 1
@msg give the path `id` podaj `id` ścieżki
@msg invalid stroke widths nieprawidłowe szerokości obrysu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6 -1
View File
@@ -692,6 +692,8 @@
Divide Vertically Into Dividir Verticalmente Em
Do you want to save the changes you made to “{name}”? Deseja salvar as alterações feitas em “{name}”?
Can't open “{name}”. Não é possível abrir “{name}”.
Can't save “{name}”. Não é possível salvar “{name}”.
Can't export “{name}”. Não é possível exportar “{name}”.
Document Documento
Document Color Mode Modo de Cor do Documento
Document Info Informações do Documento
@@ -3449,6 +3451,8 @@ axis Both Ambos
@msg recovery copy `{file}`: {e} cópia de recuperação `{file}`: {e}
@msg resolutions must be between 1 and 2400 ppi as resoluções devem estar entre 1 e 2400 ppi
@msg result would exceed the canvas (coordinates out of range) o resultado excederia a tela (coordenadas fora do intervalo)
@msg objects can't be nested more than 32 deep os objetos não podem ser aninhados em mais de 32 níveis
@msg the SVG's elements are nested more than {0} deep os elementos do SVG estão aninhados em mais de {0} níveis
@msg scale must be a number from {_1} to {_2}, not {_3} a escala deve ser um número de {_1} a {_2}, não {_3}
@msg scale must be a number from {_1} to {_2}, not {s} a escala deve ser um número de {_1} a {_2}, não {s}
@msg select (or target) objects whose appearance isn't basic selecione (ou direcione) objetos cuja aparência não seja básica
@@ -3748,7 +3752,8 @@ axis Both Ambos
@msg unknown parameter `{key}`; the command takes no parameters parâmetro desconhecido `{key}`; o comando não tem parâmetros
@msg unknown parameter `{key}`; the command takes: {keys} parâmetro desconhecido `{key}`; o comando aceita: {keys}
Load EPS into Clipboard Carregar EPS na Área de Transferência
Drawer menu Menu da gaveta
Toolbar: Show Controls Barra de ferramentas: Mostrar controles
# Corner Radius: a field per corner of a live rectangle.
Single Corner Radius Raio de Canto Único
Top Left: Superior Esquerdo:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+13
View File
@@ -766,6 +766,8 @@
Do Not Move Не перемещать
Do you want to save the changes you made to “{name}”? Сохранить изменения, внесённые в «{name}»?
Can't open “{name}”. Не удаётся открыть «{name}».
Can't save “{name}”. Не удаётся сохранить «{name}».
Can't export “{name}”. Не удаётся экспортировать «{name}».
Document Документ
Document Color Mode Цветовой режим документа
Document Info Сведения о документе
@@ -3800,6 +3802,8 @@ axis Both Оба
@msg recovery copy `{file}`: {e} копия восстановления `{file}`: {e}
@msg resolutions must be between 1 and 2400 ppi разрешения должны быть от 1 до 2400 ppi
@msg result would exceed the canvas (coordinates out of range) результат превысил бы холст (координаты вне диапазона)
@msg objects can't be nested more than 32 deep объекты нельзя вкладывать глубже 32 уровней
@msg the SVG's elements are nested more than {0} deep элементы SVG вложены глубже {0} уровней
@msg scale must be a number from {_1} to {_2}, not {_3} масштаб должен быть числом от {_1} до {_2}, а не {_3}
@msg scale must be a number from {_1} to {_2}, not {s} масштаб должен быть числом от {_1} до {_2}, а не {s}
@msg select (or target) objects whose appearance isn't basic выделите (или укажите как объект) объекты, оформление которых не является основным
@@ -4310,6 +4314,15 @@ lighting Light Свет
@msg unknown parameter `{key}`; the command takes: {keys} неизвестный параметр `{key}`; команда принимает: {keys}
Load EPS into Clipboard Загрузить EPS в буфер обмена
@msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки
Basic Базовый
Fill and Stroke Controls Элементы управления заливкой и обводкой
Coloring Controls Элементы управления цветом
Draw Mode Controls Элементы управления режимом рисования
Screen Mode Controls Элементы управления режимом экрана
Drawer menu Меню ящика
Toolbar: Show Controls Панель инструментов: Показать элементы управления
@msg control must be fillStroke, coloring, drawMode or screenMode control должен быть fillStroke, coloring, drawMode или screenMode
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode неизвестный элемент управления `{name}`: fillStroke, coloring, drawMode или screenMode
@msg give a fraction `t` from 0 to 1 укажите значение `t` от 0 до 1
@msg give the path `id` укажите `id` контура
@msg invalid stroke widths недопустимые значения ширины обводки
Can't render this file because it contains an unexpected character in line 2 and column 73.
+13
View File
@@ -762,6 +762,8 @@
Do Not Move Не переміщати
Do you want to save the changes you made to “{name}”? Зберегти зміни, внесені до «{name}»?
Can't open “{name}”. Не вдається відкрити «{name}».
Can't save “{name}”. Не вдається зберегти «{name}».
Can't export “{name}”. Не вдається експортувати «{name}».
Document Документ
Document Color Mode Колірний режим документа
Document Info Відомості про документ
@@ -3788,6 +3790,8 @@ axis Both Обидві
@msg recovery copy `{file}`: {e} копія відновлення `{file}`: {e}
@msg resolutions must be between 1 and 2400 ppi дозволи мають бути від 1 до 2400 ppi
@msg result would exceed the canvas (coordinates out of range) результат перевищив би полотно (координати поза діапазоном)
@msg objects can't be nested more than 32 deep об’єкти не можна вкладати глибше ніж на 32 рівні
@msg the SVG's elements are nested more than {0} deep елементи SVG вкладено глибше ніж на {0} рівнів
@msg scale must be a number from {_1} to {_2}, not {_3} масштаб має бути числом від {_1} до {_2}, а не {_3}
@msg scale must be a number from {_1} to {_2}, not {s} масштаб має бути числом від {_1} до {_2}, а не {s}
@msg select (or target) objects whose appearance isn't basic виділіть (або вкажіть як об’єкт) об’єкти, оформлення яких не є основним
@@ -4294,6 +4298,15 @@ lighting Light Світло
@msg unknown parameter `{key}`; the command takes: {keys} невідомий параметр `{key}`; команда приймає: {keys}
Load EPS into Clipboard Завантажити EPS у буфер обміну
@msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення
Basic Базовий
Fill and Stroke Controls Елементи керування заливкою та обведенням
Coloring Controls Елементи керування кольором
Draw Mode Controls Елементи керування режимом малювання
Screen Mode Controls Елементи керування режимом екрана
Drawer menu Меню шухляди
Toolbar: Show Controls Панель інструментів: Показати елементи керування
@msg control must be fillStroke, coloring, drawMode or screenMode control має бути fillStroke, coloring, drawMode або screenMode
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode невідомий елемент керування `{name}`: fillStroke, coloring, drawMode або screenMode
@msg give a fraction `t` from 0 to 1 вкажіть значення `t` від 0 до 1
@msg give the path `id` вкажіть `id` контуру
@msg invalid stroke widths неприпустимі значення ширини обведення
Can't render this file because it contains an unexpected character in line 713 and column 40.
+13
View File
@@ -401,6 +401,8 @@
Calligraphic Thư pháp
Calligraphic Brush Options Tùy chọn cọ thư pháp
Can't open “{name}”. Không thể mở “{name}”.
Can't save “{name}”. Không thể lưu “{name}”.
Can't export “{name}”. Không thể xuất “{name}”.
Can't print these settings: see the Summary Không thể in các cài đặt này: xem Tóm tắt
Cancel Hủy
Cancel Key Object Hủy đối tượng chính
@@ -4071,6 +4073,8 @@ theme Appearance Giao diện
@msg resolutions must be between 1 and 2400 ppi độ phân giải phải nằm trong khoảng 1 đến 2400 ppi
@msg Restored {_1} recovered document(s): save them to keep them Đã phục hồi {_1} tài liệu: hãy lưu để giữ lại
@msg result would exceed the canvas (coordinates out of range) kết quả sẽ vượt quá vùng vẽ (tọa độ ngoài phạm vi)
@msg objects can't be nested more than 32 deep không thể lồng các đối tượng sâu quá 32 cấp
@msg the SVG's elements are nested more than {0} deep các phần tử SVG lồng nhau sâu quá {0} cấp
@msg Save for Web: the image is empty Lưu cho Web: ảnh trống
@msg Save writes {_1}, not {_2}: use document.export for other formats Lưu ghi {_1}, không phải {_2}: dùng document.export cho các định dạng khác
@msg saved panel layout is invalid; reset the workspace first bố cục bảng đã lưu không hợp lệ; hãy đặt lại không gian làm việc trước
@@ -4296,6 +4300,15 @@ theme Appearance Giao diện
@msg {tool} isn't in a toolbar group of several tools {tool} không nằm trong nhóm thanh công cụ có nhiều công cụ
Load EPS into Clipboard Tải EPS vào bảng tạm
@msg an image of {w} × {h} pixels is too large to draw ảnh {w} × {h} điểm ảnh quá lớn nên không thể vẽ
Basic Cơ bản
Fill and Stroke Controls Điều khiển tô và nét
Coloring Controls Điều khiển màu
Draw Mode Controls Điều khiển chế độ vẽ
Screen Mode Controls Điều khiển chế độ màn hình
Drawer menu Menu ngăn kéo
Toolbar: Show Controls Thanh công cụ: Hiện điều khiển
@msg control must be fillStroke, coloring, drawMode or screenMode control phải là fillStroke, coloring, drawMode hoặc screenMode
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode điều khiển không xác định `{name}`: fillStroke, coloring, drawMode hoặc screenMode
@msg give a fraction `t` from 0 to 1 hãy cung cấp giá trị `t` từ 0 đến 1
@msg give the path `id` hãy cung cấp `id` đường dẫn
@msg invalid stroke widths chiều rộng nét không hợp lệ
Can't render this file because it contains an unexpected character in line 3644 and column 10.
+10 -1
View File
@@ -723,6 +723,8 @@
Do Not Move 不移动
Do you want to save the changes you made to “{name}”? 要存储对“{name}”所做的更改吗?
Can't open “{name}”. 无法打开“{name}”。
Can't save “{name}”. 无法保存“{name}”。
Can't export “{name}”. 无法导出“{name}”。
Document 文档
Document Color Mode 文档颜色模式
Document Info 文档信息
@@ -3902,7 +3904,14 @@ lighting Light 光源
@msg unknown parameter `{key}`; the command takes: {keys} 未知参数 `{key}`;该命令接受:{keys}
Load EPS into Clipboard 将 EPS 载入剪贴板
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制
Fill and Stroke Controls 填色和描边控件
Coloring Controls 颜色控件
Draw Mode Controls 绘图模式控件
Screen Mode Controls 屏幕模式控件
Drawer menu 抽屉菜单
Toolbar: Show Controls 工具栏:显示控件
@msg control must be fillStroke, coloring, drawMode or screenMode control 必须是 fillStroke、coloring、drawMode 或 screenMode
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode 未知控件 `{name}`:fillStroke、coloring、drawMode 或 screenMode
# Corner Radius: a field per corner of a live rectangle.
Single Corner Radius 统一圆角半径
Top Left: 左上:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11 -1
View File
@@ -741,6 +741,8 @@
Do Not Move 不移動
Do you want to save the changes you made to “{name}”? 要儲存對「{name}」所做的變更嗎?
Can't open “{name}”. 無法開啟「{name}」。
Can't save “{name}”. 無法儲存「{name}」。
Can't export “{name}”. 無法匯出「{name}」。
Document 文件
Document Color Mode 文件色彩模式
Document Info 文件資訊
@@ -3683,7 +3685,15 @@ lighting Light 光源
@msg unknown parameter `{key}`; the command takes: {keys} 未知的參數 `{key}`;此指令接受:{keys}
Load EPS into Clipboard 將 EPS 載入剪貼簿
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的影像過大,無法繪製
Basic 基本
Fill and Stroke Controls 填色和筆畫控制項
Coloring Controls 顏色控制項
Draw Mode Controls 繪圖模式控制項
Screen Mode Controls 螢幕模式控制項
Drawer menu 抽屜選單
Toolbar: Show Controls 工具列:顯示控制項
@msg control must be fillStroke, coloring, drawMode or screenMode control 必須是 fillStroke、coloring、drawMode 或 screenMode
@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode 未知控制項 `{name}`:fillStroke、coloring、drawMode 或 screenMode
# Corner Radius: a field per corner of a live rectangle.
Single Corner Radius 統一圓角半徑
Top Left: 左上:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+118 -8
View File
@@ -167,6 +167,29 @@ pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) {
dialogs::confirm::tell(app, &message, &crate::i18n::message(crate::i18n::current(), e));
}
/// A save or an export that didn't write `path`: say so in a dialog that names the file and the
/// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which
/// the caller's error becomes, is replaced by the next message, and a document that wasn't saved
/// must not look saved: it stays modified, and what was at `path` is as it was. Not for a request
/// an agent sent and waits on: the error is its reply.
pub(crate) fn report_write_error(app: &mut VectorcraftApp, export: bool, path: &str, e: &str) {
if app.ui.answering_control {
return;
}
let name = fileio::file_name(path);
let message = if export { tl!("Can't export “{name}”.") } else { tl!("Can't save “{name}”.") };
dialogs::confirm::tell(app, &crate::i18n::fmt(message, &[("name", &name)]), &crate::i18n::message(crate::i18n::current(), e));
}
/// [`write_to`], a failure reported ([`report_write_error`]).
pub(crate) fn write_reporting(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> {
let r = write_to(&mut app.services, path, bytes);
if let Err(e) = &r {
report_write_error(app, false, path, e);
}
r
}
/// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). On
/// the desktop a document is decoded off the UI thread ([`crate::opening`]): the result is then
/// `{background: true, status}`, and the document opens in its tab (or the failure is reported)
@@ -239,8 +262,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option<String>) -> Resu
/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`.
pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
if let Some(dl) = services.download.as_mut() {
dl(&fileio::file_name(path), bytes);
return Ok(());
return dl(&fileio::file_name(path), bytes);
}
services.write.as_mut().ok_or("no file writer")?(path, bytes)
}
@@ -260,7 +282,7 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name:
pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })?
}
};
write_to(&mut app.services, &path, bytes)?;
write_reporting(app, &path, bytes)?;
Ok(path)
}
@@ -516,8 +538,10 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String>
}
.map_err(|e| e.to_string())
};
let failed = target.clone().unwrap_or_default();
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
let r = r?;
// Not written: the document keeps its file, format and modified state, and the user is told.
let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?;
// The document may have closed meanwhile: the file is written all the same.
done.complete(&mut app.session, uid);
let path = r["path"].as_str().unwrap_or_default().to_string();
@@ -602,8 +626,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
Ok(out)
};
let enabled = app.session.prefs.background_export;
let mut r = background::run(app, enabled, label, None, work, |app, r| {
let r = r?;
let failed = target.clone();
let mut r = background::run(app, enabled, label, None, work, move |app, r| {
let r = r.inspect_err(|e| report_write_error(app, true, &failed, e))?;
let what = match r["files"].as_array() {
Some(files) => format!("{} files", files.len()),
None => r["path"].as_str().unwrap_or_default().to_string(),
@@ -673,7 +698,7 @@ pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut par
Some(p) => p,
None => target_path(app, None, ext)?,
};
write_to(&mut app.services, &path, &bytes)?;
write_reporting(app, &path, &bytes)?;
Ok((path, v))
}
@@ -768,7 +793,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) ->
} else if let Some(download) = app.services.download.as_mut() {
for (name, data) in &returned {
let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?;
download(name, &bytes);
download(name, &bytes)?;
}
let what = match returned.as_slice() {
[(name, _)] => (*name).to_string(),
@@ -830,6 +855,91 @@ mod tests {
assert!(app.ui.dialog.is_none());
}
/// #1685: a save or an export that can't write its file says so in a dialog that names the
/// file and the reason and stays until it is closed; the document stays modified and keeps
/// the file it had. An agent's request only gets the error, and a message that arrives while
/// another dialog is open waits for it to close.
#[test]
fn a_save_that_cannot_write_is_reported_in_a_dialog_that_stays() {
let refuse: crate::WriteFn = Box::new(|p: &str, _: &[u8]| Err(format!("{p}: Access is denied. (os error 5)")));
let mut app = VectorcraftApp::new(Session::new(), Services { write: Some(refuse), ..Default::default() });
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
let unsaved = |app: &VectorcraftApp| {
let st = app.session.doc().unwrap();
(st.is_dirty(), st.path.clone())
};
for (command, path, message) in [
("file.save", "/docs/Poster.vectorcraft", "Can't save “Poster.vectorcraft”."),
("file.saveAs", "/docs/Poster.vectorcraft", "Can't save “Poster.vectorcraft”."),
("file.saveCopy", "/docs/Copy.vectorcraft", "Can't save “Copy.vectorcraft”."),
("file.exportAs", "/docs/Poster.png", "Can't export “Poster.png”."),
("file.export.pdf", "/docs/Poster.pdf", "Can't save “Poster.pdf”."),
] {
let e = app.run(command, json!({"path": path})).unwrap_err();
assert!(e.contains("Access is denied"), "{command}: {e}");
let d = app.ui.dialog.take().unwrap_or_else(|| panic!("{command}: a dialog says so"));
assert_eq!((d.kind.as_str(), d.str("message").as_str()), (dialogs::confirm::MESSAGE, message), "{command}");
assert_eq!(d.str("detail"), format!("{path}: Access is denied. (os error 5)"), "{command}");
assert_eq!(unsaved(&app), (true, None), "{command}");
}
// An agent waiting on its request gets the error as its reply, and no dialog.
app.ui.answering_control = true;
assert!(app.run("file.save", json!({"path": "/docs/Poster.vectorcraft"})).is_err());
app.ui.answering_control = false;
assert!(app.ui.dialog.is_none() && app.ui.told.is_empty());
// Under another dialog (a background save that fails later) the message waits its turn,
// once, and is drawn when that dialog is closed.
dialogs::confirm::ask(&mut app, "Delete the swatch?", "", "edit.undo", json!({}));
for _ in 0..2 {
report_write_error(&mut app, false, "/docs/Poster.vectorcraft", "the disk is full");
}
assert_eq!((app.ui.dialog.as_ref().map(|d| d.kind.as_str()), app.ui.told.len()), (Some(dialogs::confirm::KIND), 1));
app.ui.dialog = None;
for _ in 0..2 {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(&mut app, ui.ctx()));
out.textures_delta.clear();
}
let d = app.ui.dialog.as_ref().expect("the message is shown, and stays");
assert_eq!(
(d.kind.as_str(), d.str("message").as_str(), d.str("detail").as_str()),
(dialogs::confirm::MESSAGE, "Can't save “Poster.vectorcraft”.", "the disk is full")
);
assert!(app.ui.told.is_empty());
}
/// #1693: a browser download that can't be started is a failed save: the document stays
/// modified and without a file, and Save Changes doesn't close it.
#[test]
fn a_download_that_cannot_start_leaves_the_document_unsaved_and_open() {
let started = Rc::new(RefCell::new(false));
let ok = started.clone();
let download: crate::DownloadFn =
Box::new(move |_: &str, _: &[u8]| if *ok.borrow() { Ok(()) } else { Err("the browser refused the download".into()) });
let mut app = VectorcraftApp::new(Session::new(), Services { download: Some(download), ..Default::default() });
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
let unsaved = |app: &VectorcraftApp| {
let st = app.session.doc().unwrap();
(st.is_dirty(), st.path.clone())
};
let e = save(&mut app, SaveMode::Save, &json!({"path": "Poster.vectorcraft"}), false).unwrap_err();
assert!(e.contains("the browser refused the download"), "{e}");
assert_eq!(unsaved(&app), (true, None));
// Save Changes › Save: not saved, so not closed.
let (i, uid) = (app.session.active_index().unwrap(), app.session.doc().unwrap().uid);
app.ui.dialog = Some(crate::state::Dialog::new(crate::unsaved::KIND, json!({"index": i, "uid": uid, "name": "Poster", "then": "close"})));
app.session.doc_mut().unwrap().path = Some("Poster.vectorcraft".into());
assert!(crate::unsaved::confirm(&mut app).is_err());
assert_eq!((app.session.documents().len(), app.session.doc().unwrap().is_dirty()), (1, true));
// Once the download starts, the save counts.
*started.borrow_mut() = true;
save(&mut app, SaveMode::Save, &json!({}), false).unwrap();
assert!(!app.session.doc().unwrap().is_dirty());
}
fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec<u8> {
let v = app.session.execute(cmd, &p).unwrap();
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
+30 -6
View File
@@ -162,7 +162,7 @@ use vectorcraft_engine::cmd::fileio;
use vectorcraft_engine::file_access::{self, AutomationRoots};
use vectorcraft_engine::{Session, ViewInfo};
pub use control::{ControlRequest, ControlResponse};
pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket};
pub use state::{UiState, View};
pub use sysclip::{ClipboardProbeFactory, SystemClipboard};
@@ -188,7 +188,9 @@ pub type PickSave = Box<dyn FnMut(&FilePick) -> Option<String>>;
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
/// Starts a browser download of the bytes under a file name; an error when it couldn't be started
/// (the save or export then fails as a refused write does: #1693).
pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
/// Opens a URL in the system browser.
pub type OpenUrlFn = Box<dyn FnMut(&str)>;
@@ -425,6 +427,8 @@ pub struct VectorcraftApp {
pub(crate) ime_marked: Option<String>,
/// The IME must drop its marked text (see [`Self::take_ime_discard`]).
pub(crate) ime_discard: bool,
/// Preferences were confirmed (see [`Self::take_prefs_changed`]).
pub(crate) prefs_changed: bool,
/// A numeric field is being scrubbed: the document's edits meanwhile are one undo step
/// ([`scrub::begin_frame`]).
scrub_group: bool,
@@ -518,6 +522,7 @@ impl VectorcraftApp {
synthetic_modifiers: false,
ime_marked: None,
ime_discard: false,
prefs_changed: false,
scrub_group: false,
automation_roots: None,
synthetic_frame: false,
@@ -766,11 +771,19 @@ impl VectorcraftApp {
let mut handled = 0;
while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME {
let Ok(req) = rx.try_recv() else { break };
// Its caller stopped waiting (the app was busy or its window not drawn for too long)
// and was told it won't run: an edit applied now would come from nowhere (#1725).
if !req.claim() {
continue;
}
handled += 1;
let reply = req.reply.clone();
// Guarded per request: a panic must not drop the channel (taken out of `self` above).
let roots = self.automation_roots.clone();
let outcome = match file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))) {
self.ui.answering_control = true;
let answer = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req)));
self.ui.answering_control = false;
let outcome = match answer {
Ok(outcome) => outcome,
Err(msg) => {
self.picks.recover();
@@ -857,6 +870,12 @@ impl VectorcraftApp {
std::mem::take(&mut self.ime_discard)
}
/// Was Preferences confirmed since the last call? The host then writes the preferences file,
/// so what the user just set survives a session that doesn't end with a normal quit (#1747).
pub fn take_prefs_changed(&mut self) -> bool {
std::mem::take(&mut self.prefs_changed)
}
/// Linked files another app changed while their document is open: every two seconds, and as
/// soon as the window comes back to the front, stamp the active document's linked files on a
/// worker thread and act on the changed ones as Preferences › File Handling › Update Links
@@ -968,8 +987,10 @@ impl VectorcraftApp {
self.ui.recent_fonts.get(..n).unwrap_or(&self.ui.recent_fonts)
}
/// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]). The
/// canvas and the transform fields read it every frame: it is measured once per revision.
/// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]); with
/// anchors selected with Direct Selection, the box around them square to the page, which
/// `object.setBounds` then edits (#1824). The canvas and the transform fields read it every
/// frame: it is measured once per revision.
pub fn selection_box(&mut self) -> Option<vectorcraft_doc::OrientedBox> {
let st = self.session.active()?;
let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds);
@@ -978,7 +999,10 @@ impl VectorcraftApp {
{
return b;
}
let b = self.session.transform_box(&st.selection.objects);
let b = match st.selection.anchor_bounds(&st.doc, self.session.prefs.use_preview_bounds) {
Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)),
None => self.session.transform_box(&st.selection.objects),
};
self.canvas.selection_box = Some((key, b));
b
}
+6
View File
@@ -262,6 +262,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state",
),
(
"toolbar.show",
"Toolbar: Show Controls",
"",
"{control?: \"fillStroke\"|\"coloring\"|\"drawMode\"|\"screenMode\", on?: bool} show (true), hide (false) or toggle (omitted) one of the controls under the toolbar's tools (Fill and Stroke, Coloring, Draw Mode, Screen Mode), as the All Tools drawer's Show section does; saved with the preferences; returns every control's state",
),
(
"window.editToolbar",
"Edit Toolbar",
+33 -1
View File
@@ -68,8 +68,14 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec<u8>) -> Result<Val
Ok(json!({ "background": true, "status": status }))
}
/// Open the files that are ready (each frame).
/// Open the files that are ready (each frame). Not while a dialog is open: a document that opens
/// becomes the active one, and the dialog's OK acts on the active document (#1694: Save As
/// options confirmed after another file had finished opening saved that file's art under the
/// first one's name). The files wait, their status line too, until the dialog is closed.
pub fn poll(app: &mut VectorcraftApp) {
if app.ui.dialog.is_some() {
return;
}
let mut i = 0;
while let Some(p) = app.opening.pending.get(i) {
let r = match p.done.try_recv() {
@@ -108,6 +114,32 @@ mod tests {
use super::*;
use crate::Services;
/// #1694: a file that finishes opening while a dialog is up waits for it to close, so the
/// dialog's document stays the active one.
#[test]
fn a_file_ready_while_a_dialog_is_open_waits_for_it_to_close() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
let (first, before) = (app.session.doc().unwrap().uid, app.session.documents().len());
app.ui.dialog = Some(crate::state::Dialog::new(crate::dialogs::save_options::KIND, json!({"path": "/art/First.vectorcraft"})));
start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap();
let t0 = std::time::Instant::now();
while t0.elapsed().as_millis() < 400 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!((app.session.documents().len(), app.session.doc().unwrap().uid), (before, first), "still the dialog's document");
assert_eq!(app.opening.pending.len(), 1);
app.ui.dialog = None;
let t0 = std::time::Instant::now();
while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 {
poll(&mut app);
std::thread::yield_now();
}
assert_eq!(app.session.documents().len(), before + 1);
assert_eq!(app.session.doc().unwrap().path.as_deref(), Some("/art/Second.vectorcraft"));
}
#[test]
fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
+37 -4
View File
@@ -5,6 +5,7 @@
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{pstate, set_pstate};
use crate::theme::Tokens;
use crate::widgets::{self, dim_label};
use crate::{VectorcraftApp, icons};
@@ -52,8 +53,10 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result<Value, S
pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let selected_id = egui::Id::new("actions-selected");
let mut selected: Option<(usize, usize)> = ui.data(|d| d.get_temp(selected_id));
// The selected action as (set, action). Read as the type it is stored as: egui keeps a
// value per type, so reading the bare pair never found it and no action stayed selected (#1629).
const SELECTED: &str = "actions-selected";
let mut selected: Option<(usize, usize)> = pstate(ui.ctx(), SELECTED);
let mut play_req = None;
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
for (si, set) in app.ui.action_sets.iter().enumerate() {
@@ -89,7 +92,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
}
}
});
ui.data_mut(|d| d.insert_temp(selected_id, selected));
set_pstate(ui.ctx(), SELECTED, selected);
widgets::divider(ui);
// Bottom bar: stop, record, play, new, delete.
ui.horizontal(|ui| {
@@ -127,7 +130,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
&& ai < s.actions.len()
{
s.actions.remove(ai);
ui.data_mut(|d| d.insert_temp::<Option<(usize, usize)>>(selected_id, None));
set_pstate::<Option<(usize, usize)>>(ui.ctx(), SELECTED, None);
}
if recording {
ui.label(egui::RichText::new(tl!("● Recording")).color(egui::Color32::from_rgb(0xe0, 0x30, 0x30)));
@@ -160,6 +163,36 @@ mod tests {
}
}
/// #1629: a clicked action stays selected, so Play Current Selection plays it and Delete
/// Selection deletes it.
#[test]
fn a_clicked_action_stays_selected_for_play_and_delete() {
use super::super::tests_appearance::{click, frame_events, text_rect};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
let id = vectorcraft_doc::NodeId(id);
let ctx = egui::Context::default();
let texts = frame_events(&ctx, &mut app, vec![], show);
click(&ctx, &mut app, text_rect(&texts, "Reflect Horizontal").center(), show);
frame_events(&ctx, &mut app, vec![], show);
assert_eq!(pstate::<Option<(usize, usize)>>(&ctx, "actions-selected"), Some((0, 5)), "selected on the frames after the click");
// The bottom bar's 24-point buttons, left to right: stop, record, play, delete.
let mut bar = crate::toolbar::tests::widget_rects(&ctx, egui::vec2(24.0, 24.0));
bar.sort_by(|a, b| a.left().total_cmp(&b.left()));
let [_, _, play, delete] = bar[..] else { panic!("the bottom bar: {bar:?}") };
click(&ctx, &mut app, text_rect(&texts, "Duplicate and Offset 10 pt").center(), show);
click(&ctx, &mut app, play.center(), show);
assert_eq!(app.session.active().unwrap().selection.len(), 1);
assert_ne!(app.session.active().unwrap().selection.objects, [id], "played: the copy is selected");
let names = |app: &VectorcraftApp| app.ui.action_sets[0].actions.iter().map(|a| a.name.clone()).collect::<Vec<_>>();
let before = names(&app);
click(&ctx, &mut app, delete.center(), show);
assert_eq!(names(&app), before[1..], "the selected action is deleted");
click(&ctx, &mut app, delete.center(), show);
assert_eq!(names(&app), before[1..], "nothing selected after a delete: nothing more goes");
}
/// Actions recorded with Align to Artboard and Paste in Place, and the default Center on
/// Artboard, play on the artboard active when they play, not on the one active when they were
/// recorded.
+7 -2
View File
@@ -646,8 +646,13 @@ mod tests {
// The web downloads the file under that name.
let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![]));
let dl = downloads.clone();
let services =
crate::Services { download: Some(Box::new(move |name: &str, _: &[u8]| dl.borrow_mut().push(name.to_string()))), ..Default::default() };
let services = crate::Services {
download: Some(Box::new(move |name: &str, _: &[u8]| {
dl.borrow_mut().push(name.to_string());
Ok(())
})),
..Default::default()
};
let mut web = VectorcraftApp::new(Session::new(), services);
web.run("file.new", json!({"width": 100, "height": 100})).unwrap();
web.run("ui.saveSwatchLibrary", json!({})).unwrap();
+3 -2
View File
@@ -155,11 +155,12 @@ pub(crate) fn go_to(app: &mut VectorcraftApp, id: u64) {
/// Write embedded image `id` to a picked file (the web downloads it) and link it there.
pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) {
if let Some(dl) = app.services.download.as_mut() {
if app.services.download.is_some() {
if let Ok(v) = app.session.execute("links.unembed", &json!({ "id": id }))
&& let (Some(name), Some(bytes)) = (v["name"].as_str(), v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode))
{
dl(name, &bytes);
// A download that can't start is said like any file that can't be written.
let _ = crate::io::write_reporting(app, name, &bytes);
}
return;
}
@@ -21,7 +21,10 @@ fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) {
let l = log.clone();
let mut services = Services::default();
if web {
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec()))));
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
l.borrow_mut().push((name.to_string(), bytes.to_vec()));
Ok(())
}));
}
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
+23
View File
@@ -486,6 +486,29 @@ mod tests {
assert_eq!(selected_guides(&app), None);
}
/// #1824: with an anchor selected with Direct Selection the panel shows that anchor's position,
/// and a new X moves it alone.
#[test]
fn an_anchor_selection_shows_and_moves_its_own_box() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let square = json!({"anchors": [{"x": 20, "y": 30}, {"x": 120, "y": 30}, {"x": 120, "y": 130}, {"x": 20, "y": 130}], "closed": true});
let id = app.run("path.create", square).unwrap()["id"].as_u64().unwrap();
let shown = texts(&mut app, show);
assert!(shown.iter().any(|t| t == "70 pt") && shown.iter().any(|t| t == "100 pt"), "the whole path: {shown:?}");
app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap();
let shown = texts(&mut app, show);
for v in ["120 pt", "30 pt", "0 pt"] {
assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}");
}
assert!(!shown.iter().any(|t| t == "70 pt" || t == "100 pt"), "{shown:?}");
app.run("object.setBounds", json!({"x": 150, "reference": 4})).unwrap();
let n = first_selected(&app).unwrap();
let Some(path) = n.path_data() else { panic!("a path") };
let xs: Vec<f64> = path.subpaths[0].anchors.iter().map(|a| a.p.x).collect();
assert_eq!(xs, [20.0, 150.0, 120.0, 20.0]);
}
#[test]
fn artboard_fields_keep_the_reference_point() {
let r = Rect::new(0.0, 0.0, 300.0, 200.0);
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more