Compare commits

..
Author SHA1 Message Date
BFlat 0d0ea352df Merge pull request #1659 from storytold/fix/1597-guide-position
Fix #1597: the Transform panel shows and sets a selected ruler guide's X or Y
2026-10-13 14:05:51 +07:00
BFlat 206e81c3b7 Merge pull request #1658 from storytold/fix/1602-intersect-snap
Fix #1602: drawn points snap to where paths and guides intersect
2026-10-13 14:05:06 +07:00
BFlat 24afe0f517 Merge pull request #1608 from dotCooCoo/feat/script-checker
Scripts: Check Script… reports what a .jsx script needs and does, without running it
2026-10-13 14:03:59 +07:00
BFlat ec67ed9646 Merge pull request #1603 from switch87/feat/1601-inkscape-pages
SVG import: Inkscape's pages become artboards
2026-10-13 14:03:38 +07:00
BFlat ce558ffb73 Merge pull request #1599 from switch87/fix/1598-filter-warning
SVG import: an unsupported filter's warning names the filter and the object
2026-10-13 14:03:18 +07:00
BFlat cbe7f83881 Merge pull request #1606 from switch87/fix/1604-relative-open-path
Open by a relative path finds relative links; convert reports import warnings
2026-10-13 14:03:03 +07:00
BFlat feb319027e Merge pull request #1611 from switch87/feat/1607-more-opentype-features
Type: stylistic sets, alternates, titling, figure styles, superscript and subscript
2026-10-13 14:02:51 +07:00
BFlat 69e77570b4 Merge pull request #1657 from storytold/u/trace-logo-scans
Image Trace: Flat Logo takes the tones of an unevenly lit colour for one colour
2026-10-13 13:58:19 +07:00
BFlat 4159ffe6fd Merge pull request #1647 from storytold/u/trace-level-bound
Image Trace: levelling moves no end of a line by more than 1.6 px
2026-10-13 13:57:40 +07:00
bflatastic 51e7044b0e Fix #1597: the Transform panel shows and sets a selected ruler guide's X or Y
With only ruler guides selected, X is the position of the vertical ones and Y
that of the horizontal ones; typing a value moves them (guide.move), together
when several are selected.
2026-10-13 13:36:41 +07:00
bflatastic dae13d57f0 Fix #1602: drawn points snap to where paths and guides intersect
A point placed by the Pen, the shape tools and the other drawing tools, and a
dragged anchor, lands where two paths cross, where a path crosses a ruler guide
and where two ruler guides cross, with the Smart Guides label "intersect".
2026-10-13 13:36:41 +07:00
dotCooCoo 3374d0778d Scripts: Check Script… reports what a .jsx script needs and does, without running it
VectorCraft does not run scripts written for the reference app, and
File › Scripts held only the disabled Other Script…. File › Scripts ›
Check Script… picks a .jsx, .jsxinc or .jsxbin file, reads it without
running any of it, and shows a report: what the script needs (the
script language's own objects, the document model, files, menu
commands, dialogs, or features only the reference app has), what it
would do outside the document, the menu-command strings it passes, the
document-model member chains and type names it uses, its directives,
the files it includes, and the problems that would stop it loading.
Copy Report copies the report as JSON. File › Open and drag-and-drop
show the report for those files too.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Such a page now moves right until its art clears the art before it.
Plain PDFs (clipped to the page) keep the 36 pt gap.
2026-10-13 06:56:37 +07:00
bflatastic c9fac95760 Image Trace: a 16 megapixel photo is traced in 0.9 GB, not 3.9
Every colour layer took a mask, component labels, a fill stack and an edge map the size of the whole image,
and as many layers were traced at once as the machine has cores.

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

Peak memory above the image itself, photo-like image (docs/image-trace-quality.md), before -> after:
- 16 megapixels: High Fidelity Photo 3,856 -> 880 MB (2.85 -> 3.65 s), Low Fidelity Photo 1,972 -> 754 MB
  (1.49 -> 1.80 s), 16 Colors 1,625 -> 298 MB, Shades of Gray 852 -> 407 MB, Default 256 -> 199 MB
- 4 megapixels: High Fidelity Photo 981 -> 643 MB, 16 Colors 408 -> 122 MB, times unchanged
The traces are the same, path for path.
2026-10-13 06:38:10 +07:00
159 changed files with 14830 additions and 752 deletions

No files matched your search

Generated
+9 -1
View File
@@ -4654,6 +4654,7 @@ dependencies = [
"vectorcraft-pdf",
"vectorcraft-plugins",
"vectorcraft-render",
"vectorcraft-scriptcheck",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
@@ -4689,6 +4690,7 @@ version = "0.10.0"
dependencies = [
"flate2",
"proptest",
"rustix 1.1.5",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -4822,6 +4824,13 @@ dependencies = [
"weezl",
]
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.10.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.10.0"
@@ -4916,7 +4925,6 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"vectorcraft-geom",
"vectorcraft-pathops",
]
[[package]]
+2
View File
@@ -37,6 +37,7 @@ vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
vectorcraft-plugins = { path = "crates/plugins" }
vectorcraft-affinity = { path = "crates/affinity" }
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -65,6 +66,7 @@ skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.5"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
rustix = { version = "1.1", default-features = false }
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
+3 -1
View File
@@ -6,7 +6,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -127,6 +127,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-13 | Image Trace: neighbouring colours share one fitted edge (gaps between them 22 to 50 px of area down to under 2), a 16 megapixel photo traces in 0.9 GB (was 3.9), stroked centre lines lie in the middle of their ink at its width (0.30 to 0.05 px off, 18 to 3 anchors a stroke), nearly level lines are levelled, more sharp corners are kept; new: Ignore Color, the Auto Color and Outline presets, and the Shapes, Auto Grouping and Gradients options (off by default) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
| 2026-10-13 | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Import: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). Live Corners: a round corner next to a curved side curves smoothly instead of sticking out of the shape, and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533). Guides: Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). |
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
| 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. |
+12 -6
View File
@@ -93,7 +93,8 @@ USAGE:
precision (1–7, default 3). OUT may not be IN when reading
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
draws differently from its PDF part (which can be older), as long as it can be read.
draws differently from its PDF part (which can be older), as long as it can be read. The
result has the export's warnings, and importWarnings for what reading IN left out.
vectorcraft-cli info FILE
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
@@ -237,19 +238,24 @@ fn convert(args: &[String]) -> Result<(), String> {
}
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
let mut h = Headless::new();
if editing_data {
let opened = if editing_data {
let params = json!({"path": input, "editingData": "always"});
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?;
h.call("engine.execute", json!({"command": "document.open", "params": params})).map_err(|e| format!("open {input}: {e}"))?
} else {
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
}
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
};
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
// much as what writing the output did.
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
out.insert("importWarnings".into(), w.clone());
}
outln!("{r}");
Ok(())
}
+16
View File
@@ -99,3 +99,19 @@ fn info_reports_why_an_eps_read_partly() {
assert!(w.contains("`frobnicate`"), "{v}");
assert_eq!(v["kinds"]["Path"], 1, "{v}");
}
/// #1604: convert reports what reading the input left out (here a missing linked image) as
/// importWarnings, next to the export's own warnings.
#[test]
fn convert_reports_import_warnings() {
let svg = tmp("missing-link.svg");
std::fs::write(
&svg,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><image width="40" height="40" href="nowhere-to-be-found.png"/></svg>"#,
)
.unwrap();
let png = tmp("missing-link.png");
let out: Value = serde_json::from_str(ok(&["convert", &svg, &png]).trim()).unwrap();
let w = out["importWarnings"].as_array().unwrap_or_else(|| panic!("{out}"));
assert!(w.iter().any(|w| w.as_str().unwrap().contains("nowhere-to-be-found.png")), "{out}");
}
+5
View File
@@ -93,3 +93,8 @@ sync_github_name = true
[people.switch87]
real_name = "Gert Pellin"
sync_github_name = false
[people.lewandowskit]
real_name = "Tomasz Lewandowski"
display_name = "Tomasz Lewandowski"
sync_github_name = false
+50 -1
View File
@@ -249,7 +249,7 @@ pub(super) fn builtin_rgb_bytes(name: &str) -> Result<Vec<u8>, CmsError> {
/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible.
pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
let mut bytes = aligned(p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?);
// Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each).
if let Some(date) = bytes.get_mut(24..36) {
date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat());
@@ -257,6 +257,55 @@ pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
Ok(bytes)
}
/// `icc` with every tagged element starting on a 4-byte boundary, padded with zeros, as ICC.1
/// §7.1.2 requires (moxcms packs them, so a 62-byte `desc` left the next tag unaligned, #1584).
/// Tags that share data keep sharing it. A profile whose tag table doesn't read is returned as it is.
fn aligned(icc: Vec<u8>) -> Vec<u8> {
const TABLE: usize = 128;
let be = |b: &[u8], at: usize| b.get(at..at + 4).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]) as usize);
let Some(n) = be(&icc, TABLE).filter(|n| *n <= 1000) else { return icc };
let data_start = TABLE + 4 + 12 * n;
let mut entries = Vec::with_capacity(n);
for i in 0..n {
let e = TABLE + 4 + 12 * i;
let (Some(off), Some(size)) = (be(&icc, e + 4), be(&icc, e + 8)) else { return icc };
if off < data_start || off.checked_add(size).is_none_or(|end| end > icc.len()) {
return icc;
}
entries.push((off, size));
}
if entries.iter().all(|(off, _)| off.is_multiple_of(4)) && icc.len().is_multiple_of(4) {
return icc;
}
let mut out = icc.get(..data_start).map(<[u8]>::to_vec).unwrap_or_default();
let mut moved: Vec<((usize, usize), usize)> = Vec::new();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| entries[i].0);
for i in order {
let (off, size) = entries[i];
let at = match moved.iter().find(|(k, _)| *k == (off, size)) {
Some((_, at)) => *at,
None => {
out.resize(out.len().next_multiple_of(4), 0);
let at = out.len();
out.extend_from_slice(&icc[off..off + size]);
moved.push(((off, size), at));
at
}
};
let e = TABLE + 4 + 12 * i + 4;
out[e..e + 4].copy_from_slice(&(at as u32).to_be_bytes());
}
out.resize(out.len().next_multiple_of(4), 0);
let len = out.len() as u32;
out[0..4].copy_from_slice(&len.to_be_bytes());
// The profile ID (an MD5 of the old bytes) no longer matches: zero is "not computed".
if let Some(id) = out.get_mut(84..100) {
id.fill(0);
}
out
}
fn text_tag(s: &str) -> Option<ProfileText> {
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
}
+23
View File
@@ -111,3 +111,26 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
let abs = |bpc| icc_cmyk(bpc).cmyk_to_rgb(rich, Intent::AbsoluteColorimetric);
assert_eq!(abs(true), abs(false));
}
/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and
/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space.
#[test]
fn written_profiles_align_their_tags() {
for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] {
let icc = super::icc_bytes(name).unwrap();
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
assert_eq!(be(0), icc.len(), "{name}: header size");
assert_eq!(icc.len() % 4, 0, "{name}: {} bytes", icc.len());
let n = be(128);
for i in 0..n {
let (off, size) = (be(132 + 12 * i + 4), be(132 + 12 * i + 8));
assert_eq!(off % 4, 0, "{name}: tag {i} at {off}");
assert!(off + size <= icc.len());
}
let back = moxcms::ColorProfile::new_from_slice(&icc).unwrap();
let orig = moxcms::ColorProfile::new_srgb();
if name == super::SRGB {
assert_eq!((back.red_colorant, back.green_colorant, back.blue_colorant), (orig.red_colorant, orig.green_colorant, orig.blue_colorant));
}
}
}
+230 -16
View File
@@ -1,5 +1,7 @@
//! Hit testing over the document tree.
use std::collections::HashSet;
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
use vectorcraft_geom::{Point, Rect};
@@ -83,6 +85,77 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let opt = HitOptions { scope: scope.or(opt.scope), ..opt };
// Normally one hit test per *object* suffices: layers and sublayers aren't selectable,
// while a group (including a clipping group) is selected as one object. Repeatedly
// searching the full tree for each hit made a stack of n overlapping paths O(n^2).
// Isolation and Edit Contents change where selection groups begin; keep the original
// traversal for those cases rather than change their Select Behind semantics.
if opt.scope.is_some() {
return objects_at_repeated(doc, p, opt, scope);
}
let mut tops = Vec::new();
let mut seen = HashSet::new();
for layer in doc.layers.iter().rev() {
// Malformed files may contain non-layer roots; let the general traversal handle them.
if !layer.is_layer() {
return objects_at_repeated(doc, p, opt, scope);
}
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
continue;
}
if !objects_at_layer(layer, p, opt, &mut tops, &mut seen) {
// An envelope in Edit Contents may expose objects *within* a top-level group.
// The repeated traversal handles that mixed hierarchy precisely.
return objects_at_repeated(doc, p, opt, scope);
}
}
tops
}
/// One traversal through a layer and its sublayers. Each selectable object's subtree is
/// inspected once (only until its first hit). `false` asks the caller to use the general
/// algorithm for an envelope whose contents are currently being edited.
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
let Some(children) = layer.children() else { return true };
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
return true;
}
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
for (i, c) in children.iter().enumerate().rev() {
if !may_hit(layer, i, c, p, opt, true) || seen.contains(&c.id) {
continue;
}
if c.is_layer() {
if !objects_at_layer(c, p, opt, tops, seen) {
return false;
}
continue;
}
// Editing an envelope selects its contents rather than the containing object. Even
// when nested in a group, the old traversal may select both a member and the group.
if c.is_container() {
let mut editing = false;
c.walk(&mut |n| editing |= edits_contents(n));
if editing {
return false;
}
}
let skip = |id| seen.contains(&id);
let mut chain = Vec::new();
let hit = match &c.kind {
NodeKind::Group { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some(),
NodeKind::Blend { .. } => hit_children(c, p, opt, &skip, &mut chain, true).is_some() || hit_leaf(c, p, opt).is_some(),
_ => hit_leaf(c, p, opt).is_some(),
};
if hit && seen.insert(c.id) {
tops.push(c.id);
}
}
true
}
/// The general selection order also handles isolation and nested Edit Contents.
fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
let mut tops: Vec<NodeId> = vec![];
// Each round leaves out the objects found so far (and everything inside them).
while let Some(top) = hit_test_skipping(doc, p, opt, &|id| tops.contains(&id), true).map(|h| h.top_object(scope)) {
@@ -150,6 +223,22 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
}
/// Can a click at `p` hit `c`, the child `i` of `parent`, or something inside it? Not when it is
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
/// isn't shown, or when `p` is outside its reach.
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, editable_only: bool) -> bool {
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
return false;
}
// Read-only samples can read locked/template art; hidden art never participates.
if !c.visible || editable_only && (c.locked || c.is_template()) {
return false;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c) || c.extras().shaper.is_some() && source_isolated(c) || c.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
}
fn hit_children(
parent: &Node,
p: Point,
@@ -167,22 +256,7 @@ fn hit_children(
return None;
}
for (i, c) in children.iter().enumerate().rev() {
if parent.extras().shaper.is_some() {
let construction = children.first().is_some_and(|source| Some(source.id) == opt.scope);
if (i == 0) != construction {
continue;
}
}
// Read-only samples can read locked/template art; hidden art never participates.
if !c.visible || editable_only && (c.locked || c.is_template()) || skip(c.id) {
continue;
}
// (An envelope's content sits where it was, not where the envelope draws it.)
if !(edits_contents(c)
|| c.extras().shaper.is_some() && c.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope))
&& let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
if !may_hit(parent, i, c, p, opt, editable_only) || skip(c.id) {
continue;
}
chain.push(c.id);
@@ -619,6 +693,146 @@ world",
assert!(at(90.0, None).is_empty());
}
/// Many overlapping paths: Select Behind must list each exactly once, front to back.
/// This is quadratic with repeated full-document hit tests, but linear in tree size with
/// the per-object walk.
#[test]
fn objects_at_large_overlapping_stack() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let mut ids = Vec::new();
for _ in 0..256 {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
ids.push(id);
}
ids.reverse();
assert_eq!(objects_at(&d, Point::new(25.0, 25.0), HitOptions::default(), None), ids);
}
/// Match the original skip-and-retest algorithm for normal groups, sublayers, clipping,
/// invisibility, and path/outline-only settings.
#[test]
fn objects_at_one_pass_matches_repeated_hits() {
let mut d = Document::new(200.0, 200.0);
let layer = d.layers[0].id;
let add = |d: &mut Document, parent, bounds| {
let id = d.alloc_id();
d.insert(Some(parent), usize::MAX, Node::path(id, shapes::rectangle(bounds), Appearance::default_art())).unwrap();
id
};
add(&mut d, layer, Rect::new(0.0, 0.0, 60.0, 60.0));
let sub = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sublayer", crate::LayerColor::Preset(2))).unwrap();
add(&mut d, sub, Rect::new(0.0, 0.0, 60.0, 60.0));
let group = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::group(group, Vec::new())).unwrap();
add(&mut d, group, Rect::new(0.0, 0.0, 60.0, 60.0));
add(&mut d, group, Rect::new(5.0, 5.0, 60.0, 60.0));
let clip = d.alloc_id();
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 45.0)), Appearance::default_art());
let content = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)), Appearance::default_art());
let mut clipping = Node::group(clip, vec![Arc::new(mask), Arc::new(content)]);
clipping.set_clips(true);
d.insert(Some(layer), usize::MAX, clipping).unwrap();
for (point, opt) in [
(Point::new(25.0, 25.0), HitOptions::default()),
(Point::new(3.0, 25.0), HitOptions::default()),
(Point::new(25.0, 25.0), HitOptions { outline: true, ..Default::default() }),
(Point::new(6.0, 25.0), HitOptions { path_only: true, ..Default::default() }),
(Point::new(100.0, 100.0), HitOptions::default()),
] {
assert_eq!(objects_at(&d, point, opt, None), objects_at_repeated(&d, point, opt, None), "point={point:?}, opt={opt:?}");
}
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(sub).unwrap().kind {
*preview = false;
}
let p = Point::new(25.0, 25.0);
let opt = HitOptions::default();
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
d.node_mut(group).unwrap().locked = true;
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
}
/// Documents built at random (nested groups, sublayers, clipping groups, compound paths, blends
/// of nothing, hidden and locked objects, outline layers): Select Behind lists the same objects
/// in the same order as the hit test repeated over what is left.
#[test]
fn objects_at_matches_repeated_hits_on_random_documents() {
let mut seed = 0x9e37_79b9_u32;
let mut next = move |n: u32| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed % n
};
fn fill(d: &mut Document, parent: NodeId, depth: u32, next: &mut impl FnMut(u32) -> u32) {
for _ in 0..2 + next(4) {
let id = d.alloc_id();
let (x, y) = (f64::from(next(60)), f64::from(next(60)));
let rect = Rect::new(x, y, x + 10.0 + f64::from(next(50)), y + 10.0 + f64::from(next(50)));
let appearance = if next(4) == 0 { Appearance::default() } else { Appearance::default_art() };
let mut n = match next(if depth < 3 { 8 } else { 4 }) {
4 | 5 => Node::group(id, Vec::new()),
6 => Node::layer(id, "Sublayer", crate::LayerColor::Preset(2)),
7 => {
let mut g = Node::group(id, Vec::new());
g.set_clips(true);
g
}
_ => Node::path(id, shapes::rectangle(rect), appearance),
};
n.visible = next(8) != 0;
n.locked = next(8) == 0;
if let NodeKind::Layer { preview, .. } = &mut n.kind {
*preview = next(3) != 0;
}
let container = n.is_container();
// A sublayer belongs in a layer; in a group it is a group like another to the hit test.
d.insert(Some(parent), usize::MAX, n).unwrap();
if container {
fill(d, id, depth + 1, next);
}
}
}
for round in 0..200 {
let mut d = Document::new(120.0, 120.0);
let layer = d.layers[0].id;
fill(&mut d, layer, 0, &mut next);
for _ in 0..12 {
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() };
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
}
}
}
/// Edit Contents can expose descendants of a top-level group: preserve the original
/// selection order by falling back to the general hit traversal.
#[test]
fn objects_at_edit_contents_uses_general_order() {
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let inner = |id| Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art()));
let (a, b) = (d.alloc_id(), d.alloc_id());
let e = Node::new(
d.alloc_id(),
NodeKind::Envelope {
content: vec![inner(a), inner(b)],
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
fidelity: 100.0,
editing: true,
options: Default::default(),
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
},
);
d.insert(Some(layer), usize::MAX, e).unwrap();
let p = Point::new(25.0, 25.0);
let opt = HitOptions::default();
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None));
assert_eq!(objects_at(&d, p, opt, None), vec![b, a]);
}
#[test]
fn locked_and_hidden_skip() {
let mut d = Document::new(200.0, 200.0);
+8
View File
@@ -1465,6 +1465,14 @@ impl Document {
pub fn new_graphic_style_name(&self) -> String {
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
}
/// `base`, or the first free "`base` N" (N from 2) when a symbol already has that name.
pub fn unique_symbol_name(&self, base: &str) -> String {
let taken = |n: &str| self.symbols.iter().any(|s| s.name == n);
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> {
+39 -8
View File
@@ -318,8 +318,10 @@ impl WarpStyle {
}
}
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1).
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) {
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1);
/// `aspect` is the box's half-height over its half-width along the warp (Arc bends in real
/// proportions).
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, aspect: f64, x: f64, y: f64) -> (f64, f64) {
use WarpStyle::*;
let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom
let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides
@@ -328,14 +330,18 @@ pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) ->
if b.abs() < 1e-6 {
(x, y)
} else {
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°).
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°), in
// the box's real proportions (lengths in half-widths, so y counts `aspect` each):
// the edges are concentric arcs, and one that would pass the centre stops there
// instead of folding over (#1578).
let k = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 };
let sweep = b.abs() * PI;
let r0 = 2.0 / sweep;
let yy = if b > 0.0 { y } else { -y };
let yy = if b > 0.0 { y } else { -y } * k;
let a = x / r0;
let r = r0 - yy;
let r = (r0 - yy).max(0.0);
let (s, c) = a.sin_cos();
let (nx, ny) = (r * s, r0 - r * c);
let (nx, ny) = (r * s, (r0 - r * c) / k);
(nx, if b > 0.0 { ny } else { -ny })
}
}
@@ -1122,9 +1128,9 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
Box::new(move |u: f64, v: f64| {
let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0);
let (x2, y2) = if horizontal {
warp_point(st, b, dh, dv, x, y)
warp_point(st, b, dh, dv, hh / hw.max(1e-9), x, y)
} else {
let (a, b2) = warp_point(st, b, dh, dv, y, x);
let (a, b2) = warp_point(st, b, dh, dv, hw / hh.max(1e-9), y, x);
(b2, a)
};
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
@@ -1711,6 +1717,31 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>]) -> f64 {
#[cfg(test)]
mod tests {
use super::*;
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
/// real distance from the centre line; an inner edge that would pass the centre stops there.
#[test]
fn arc_warp_is_round_in_real_proportions() {
for (aspect, b) in [(0.25, 0.1), (0.25, 0.5), (0.25, 1.0), (1.0, 0.5), (2.0, 0.3)] {
let r0 = 2.0 / (b * PI);
for (y, r) in [(-1.0, r0 + aspect), (1.0, (r0 - aspect).max(0.0))] {
for i in 0..=10 {
let x = -1.0 + 0.2 * f64::from(i);
let (nx, ny) = warp_point(WarpStyle::Arc, b, 0.0, 0.0, aspect, x, y);
let d = nx.hypot(ny * aspect - r0);
assert!((d - r).abs() < 1e-9, "aspect {aspect}, bend {b}, y {y}, x {x}: {d} vs {r}");
}
}
}
// At 100 % on a square the inner edge meets at the centre instead of folding over.
let (nx, _) = warp_point(WarpStyle::Arc, 1.0, 0.0, 0.0, 1.0, 0.8, 1.0);
assert!(nx.abs() < 1e-12);
// A square bent less than that is what it always was.
let (nx, ny) = warp_point(WarpStyle::Arc, 0.5, 0.0, 0.0, 1.0, 0.5, 0.25);
let (r0, a) = (4.0 / PI, 0.5 / (4.0 / PI));
assert!((nx - (r0 - 0.25) * a.sin()).abs() < 1e-12 && (ny - (r0 - (r0 - 0.25) * a.cos())).abs() < 1e-12);
}
use vectorcraft_geom::shapes;
fn rect_node(x: f64, y: f64, w: f64, h: f64, c: Color) -> Arc<Node> {
+10 -2
View File
@@ -1,6 +1,7 @@
//! Distort & Transform effects.
use serde_json::Value;
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
use crate::util::*;
@@ -194,7 +195,10 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
map_nonlinear(path, pieces, f)
}
/// Zig Zag: `ridges` extra points per segment, alternately displaced along the normal.
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
/// displaced along the normal.
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
@@ -207,8 +211,12 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let mut k = 0usize;
for i in 0..segs {
let c = sp.segment(i);
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
// near the anchors, and so does any uneven curve.
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
for j in 0..=ridges {
let t = j as f64 / (ridges + 1) as f64;
let s = j as f64 / (ridges + 1) as f64;
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
let nrm = if j == 0 {
// Average the normals of the segments meeting at the anchor.
let prev = if i > 0 {
+1 -1
View File
@@ -249,7 +249,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"distort.freeDistort",
"Free Distort…",
DT,
"{corners: [[x,y]×4] new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates (identity [[0,0],[1,0],[1,1],[0,1]])}",
"{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}",
json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}),
),
g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})),
+27 -1
View File
@@ -116,10 +116,36 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
out
}
(None, None) => plain_outline(line, &style(st, width), tol),
(None, None) => {
let mut out = plain_outline(line, &style(st, width), tol);
out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter());
out
}
}
}
/// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as
/// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG
/// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows
/// (SVG 2, "Path data": zero-length path segments).
fn point_dots(line: &BezPath) -> Vec<Dot> {
let els = line.elements();
subpaths(line)
.into_iter()
.filter_map(|(r, _)| {
let sub = els.get(r)?;
let at = sub.first()?.end_point()?;
let still = sub.iter().all(|e| match e {
PathEl::MoveTo(p) | PathEl::LineTo(p) => *p == at,
PathEl::QuadTo(a, b) => *a == at && *b == at,
PathEl::CurveTo(a, b, c) => *a == at && *b == at && *c == at,
PathEl::ClosePath => true,
});
still.then_some(Dot { at, dir: kurbo::Vec2::new(1.0, 0.0), t: 0.0 })
})
.collect()
}
/// `line` stroked with `style`, to fill with the non-zero rule.
///
/// kurbo offsets a closed subpath to both sides, as two contours. Once half the weight is more
+56
View File
@@ -244,6 +244,62 @@ fn zigzag_ridge_count_and_amplitude() {
assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}");
}
#[test]
fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
// #1554: on a straight line every ridge is the same distance from the line and as far from
// the next one, whether the points are corners or smooth.
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.0, 500.0)], false));
for points in ["corner", "smooth"] {
let out = run("distort.zigZag", json!({"ridges": 4, "points": points, "size": 20}), &line);
let ps: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(ps.len(), 6, "{points}");
for (k, q) in ps.iter().enumerate() {
assert!(close(q.x.abs(), 20.0, 1e-6), "{points} {k}: {q:?}");
assert!(close(q.y, 100.0 * k as f64, 1e-3), "{points} {k}: {q:?}");
}
}
}
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
/// turn all the way round.
#[test]
fn zigzag_on_a_circle_is_regular() {
let circle = shapes::ellipse(Rect::new(-50.0, -50.0, 50.0, 50.0));
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 10}), &circle);
let pts: Vec<Point> = out.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(pts.len(), 16);
let start = pts[0].y.atan2(pts[0].x);
let mut radii = vec![];
for (i, q) in pts.iter().enumerate() {
let turn = (q.y.atan2(q.x) - start).to_degrees().abs() / 22.5;
assert!(close(turn, turn.round(), 0.02), "point {i} at {turn} sixteenths");
radii.push(q.to_vec2().hypot().round());
}
assert!(radii.iter().all(|r| *r == 60.0 || *r == 40.0) && (0..16).all(|i| radii[i] != radii[(i + 1) % 16]), "{radii:?}");
}
/// #1554 on an uneven curve: the ridges divide its length, not its parameter, into equal parts.
#[test]
fn zigzag_spaces_ridges_by_length_on_an_uneven_curve() {
use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0));
let curve = PathData::single(SubPath::new(
vec![
vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)),
vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b),
],
false,
));
let c = curve.subpaths[0].segment(0);
let len = c.arclen(1e-6);
let out = run("distort.zigZag", json!({"ridges": 3, "points": "corner", "size": 0}), &curve);
let along: Vec<f64> = out.subpaths[0].anchors.iter().map(|q| c.subsegment(0.0..c.nearest(q.p, 1e-9).t).arclen(1e-6) / len).collect();
assert_eq!(along.len(), 5);
for (k, share) in along.iter().enumerate() {
assert!(close(*share, k as f64 / 4.0, 1e-3), "{along:?}");
}
}
#[test]
fn offset_path_grows_and_shrinks() {
let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap();
+2 -2
View File
@@ -22,10 +22,10 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
let f = |q: Point| {
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
let (x2, y2) = if vertical {
let (a, b2) = warp_point(style, bend, dh, dv, y, x);
let (a, b2) = warp_point(style, bend, dh, dv, hw / hh, y, x);
(b2, a)
} else {
warp_point(style, bend, dh, dv, x, y)
warp_point(style, bend, dh, dv, hh / hw, x, y)
};
Point::new(c.x + x2 * hw, c.y + y2 * hh)
};
+1
View File
@@ -26,6 +26,7 @@ vectorcraft-eps = { workspace = true }
vectorcraft-metafile = { workspace = true }
vectorcraft-plugins = { workspace = true }
vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
kurbo = { workspace = true }
+2 -10
View File
@@ -526,14 +526,6 @@ fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
Ok((art, (w, h), c))
}
fn unique_symbol_name(d: &Document, base: &str) -> String {
let taken = |n: &str| d.symbols.iter().any(|s| s.name == n);
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
@@ -587,7 +579,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let (art, size, c) = normalize(art)?;
let name = unique_symbol_name(&s.doc()?.doc, str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
let id = s.edit("New Symbol", |d, sel| {
let art = d.reid(&art);
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
@@ -764,7 +756,7 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, C)?.to_string();
let d = &s.doc()?.doc;
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
let new = unique_symbol_name(d, str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let size = natural_size(d, &name);
s.edit("Duplicate Symbol", |d, _| {
let art = d.reid(&sym.art);
+83 -4
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection, TraceView};
use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView};
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
use vectorcraft_pathops as po;
use vectorcraft_tools::builder::{
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
@@ -703,6 +703,76 @@ fn trace_library(s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8;
/// refused with advice instead (#525).
pub const MAX_TRACE_ANCHORS: usize = 2_000_000;
/// The live shape for a shape the tracer found (in the image's pixels), placed in the document by `xf`.
fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveShape {
use vectorcraft_geom::Affine;
let about = |c: Point, deg: f64| Affine::translate(c.to_vec2()) * Affine::rotate(deg.to_radians());
let mut live = match shape {
tr::TracedShape::Rectangle { center, width, height, radius, rotation } => LiveShape::Rectangle {
w: width,
h: height,
radii: [radius; 4],
kinds: Default::default(),
xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)),
},
tr::TracedShape::Ellipse { center, rx, ry, rotation } => {
LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) }
}
tr::TracedShape::Polygon { center, radius, sides, rotation } => {
LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] }
}
};
// (Whether the move changed the shape's own numbers does not matter: its path is made from them next.)
let _ = live.transform(xf);
live
}
/// The gradient fill for a linear gradient the tracer found (in the image's pixels), placed in the
/// document by `xf`.
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect();
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint))
}
/// Auto Grouping: `nodes` (one per traced path, bottom-most first) with each path that has others
/// inside it (`parents`, as the tracer gives them) in a group with them: the path, then what is
/// inside it. Without parents, the nodes as they are.
fn grouped(d: &mut Document, nodes: Vec<Node>, parents: &[Option<usize>]) -> Vec<Arc<Node>> {
let n = nodes.len();
let mut inside: Vec<Vec<usize>> = vec![vec![]; n];
let mut roots = vec![];
for i in 0..n {
match parents.get(i).copied().flatten().and_then(|p| inside.get_mut(p).filter(|_| p != i)) {
Some(of) => of.push(i),
None => roots.push(i),
}
}
// Outermost first, so that backwards each path comes after those inside it.
let mut order = roots.clone();
let mut at = 0;
while let Some(&i) = order.get(at) {
order.extend(inside.get(i).into_iter().flatten());
at += 1;
}
let mut nodes: Vec<Option<Node>> = nodes.into_iter().map(Some).collect();
let mut built: Vec<Option<Arc<Node>>> = vec![None; n];
for &i in order.iter().rev() {
let Some(node) = nodes.get_mut(i).and_then(Option::take) else { continue };
let within: Vec<Arc<Node>> = inside.get(i).into_iter().flatten().filter_map(|&c| built.get_mut(c)?.take()).collect();
let made = if within.is_empty() { node } else { Node::group(d.alloc_id(), std::iter::once(Arc::new(node)).chain(within).collect()) };
if let Some(slot) = built.get_mut(i) {
*slot = Some(Arc::new(made));
}
}
// (Paths that are inside each other in a ring are reached from no root: the tracer makes none,
// and they are kept all the same.)
let ring = nodes.into_iter().flatten().map(Arc::new).collect::<Vec<_>>();
roots.iter().filter_map(|&r| built.get_mut(r)?.take()).chain(ring).collect()
}
fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
const C: &str = "imageTrace.make";
let mut params = trace_params(p)?;
@@ -763,12 +833,20 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
hidden.visible = false;
children.push(Arc::new(hidden));
}
let mut traced = Vec::with_capacity(res.paths.len());
for tp in res.paths {
let path = tp.path.transformed(xf);
let mut n = shape_node(d, path, None);
// Shapes: a path the tracer found to be a shape is that live shape.
if let (Some(shape), NodeKind::Path { path, live, .. }) = (tp.shape, &mut n.kind) {
let shape = traced_shape(shape, xf);
*path = shape.to_path();
*live = Some(Box::new(shape));
}
// A library colour fills with the swatch's own colour (its model kept).
let color = library.iter().find(|(rgb, _)| *rgb == tp.color).map_or_else(|| Color::rgb8(tp.color[0], tp.color[1], tp.color[2]), |l| l.1);
let paint = Paint::solid(color);
// Gradients: a path the tracer found to be a linear gradient is filled with it.
let paint = tp.gradient.as_ref().map_or_else(|| Paint::solid(color), |g| traced_gradient(g, xf));
n.appearance = match tp.stroke {
// Traced lines: round ends and corners, as drawn lines have.
Some(width) => Appearance {
@@ -782,8 +860,9 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
.into(),
None => fill_only(paint).into(),
};
children.push(Arc::new(n));
traced.push(n);
}
children.extend(grouped(d, traced, &res.parents));
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = if expand { None } else { Some(TRACE_NAME.into()) };
+4 -2
View File
@@ -489,8 +489,10 @@ fn release_guides(s: &mut Session, _: &Value) -> Result<Value> {
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.guides_locked);
s.menu.guides_locked = v;
// Locked guides can't stay selected.
if v && !s.doc()?.selection.guides.is_empty() {
// Locked guides can't stay selected, ruler guides or guide paths ([`Session::select`] lets
// those go).
let st = s.doc()?;
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
s.select(|_, sel| sel.guides.clear())?;
}
Ok(json!({ "locked": v }))
+54 -6
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use kurbo::{ParamCurve, ParamCurveNearest};
use kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};
@@ -1048,17 +1048,46 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio
open.anchors.push(open.anchors[0]);
m.push(false);
open.closed = false;
let mut res = smooth_open(&open.anchors, &m, tol);
let mut res = smooth_open(&open.anchors, &m, near, tol);
let l = res.pop()?;
if let Some(r0) = res.first_mut() {
r0.h_in = l.h_in;
}
return Some(SubPath::new(res, true));
}
Some(SubPath::new(smooth_open(&sp.anchors, &marked, tol), false))
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol), false))
}
fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
/// enters the reach: the part outside, then the part in reach. `None` when no more than a sliver
/// is outside.
fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez, CubicBez)> {
const STEPS: u32 = 32;
let step = 1.0 / f64::from(STEPS);
// Back from the end in reach to the first sample outside, then halving to the crossing.
let outside = (1..=STEPS).map(|i| 1.0 - f64::from(i) * step).find(|t| !near(c.eval(*t)))?;
let (mut lo, mut hi) = (outside, outside + step);
for _ in 0..20 {
let mid = (lo + hi) / 2.0;
if near(c.eval(mid)) {
hi = mid;
} else {
lo = mid;
}
}
// A straight segment's parts stay straight, their handles on their ends.
let straight = c.p1 == c.p0 && c.p2 == c.p3;
let part = |range| {
let s = c.subsegment(range);
if straight { CubicBez::new(s.p0, s.p0, s.p3, s.p3) } else { s }
};
(hi > 0.02).then(|| (part(0.0..hi), part(hi..1.0)))
}
/// `anchors` with each run of `marked` ones refitted, together with the stretch of the segments
/// leading into the run that is in the drag's reach (`near`): a short drag smooths what is under
/// it, and the rest of a long segment next to it stays as it was (#1512).
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64) -> Vec<Anchor> {
let n = anchors.len();
let mut runs = vec![];
let mut i = 0;
@@ -1079,8 +1108,27 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], tol: f64) -> Vec<Anchor> {
if hi - lo < 2 {
continue;
}
let new = refit_run(&out[lo..=hi], tol);
out.splice(lo..=hi, new);
let mut piece = out[lo..=hi].to_vec();
// The anchors outside the reach, kept with the part of their segment that is outside.
let (mut head, mut tail) = (None, None);
if hi > b
&& let [.., inner, outer] = piece.as_mut_slice()
&& let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_in, inner.h_out, inner.p), near)
{
inner.h_out = reach.p2;
tail = Some(Anchor { h_in: far.p1, ..*outer });
*outer = Anchor::with_handles(far.p3, reach.p1, far.p2);
}
if lo < a
&& let [outer, inner, ..] = piece.as_mut_slice()
&& let Some((far, reach)) = split_at_reach(CubicBez::new(outer.p, outer.h_out, inner.h_in, inner.p), near)
{
inner.h_in = reach.p2;
head = Some(Anchor { h_out: far.p1, ..*outer });
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
}
let new = refit_run(&piece, tol);
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
}
out
}
+9 -1
View File
@@ -132,7 +132,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection_or_guides,
clear
),
cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate),
cmd!(
"edit.duplicate",
"Duplicate",
["Edit"],
None,
"{dx?, dy?} duplicate the selection in place (offset optional)",
has_selection,
duplicate
),
]
}
+15 -1
View File
@@ -280,6 +280,20 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": item.flatten() }))
}
/// The ids of the effects `effect.setParams` with `p` edits, for the check of their parameters
/// (none when `p` doesn't say which: running it reports why).
pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
const C: &str = "effect.setParams";
let (Ok(index), Ok(item), Ok(ids), Ok(st)) = (index_param(p, "index", C), item_target(s, p, C), appearance_targets(s, p), s.doc()) else {
return vec![];
};
let effect = |id: &NodeId| {
let ap = &st.doc.node(*id)?.appearance;
Some(ap.effects_at(item.effects_item(ap, C).ok()?)?.get(index)?.id.clone())
};
ids.iter().filter_map(effect).collect()
}
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;
@@ -566,7 +580,7 @@ mod tests {
for other in ["threeD.revolve", "threeD.extrude", "threeD.rotate"] {
assert!(s.execute("effect.apply", &json!({"effect": other, "ids": [id.0], "item": null})).is_err(), "{effect} + {other}");
}
s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "depth": 120}, "ids": [id.0], "item": null})).unwrap();
s.execute("effect.setParams", &json!({"index": 0, "params": {"rotationY": 50, "rotationX": 10}, "ids": [id.0], "item": null})).unwrap();
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "{effect}: options change the art");
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err());
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
+6 -2
View File
@@ -382,12 +382,16 @@ pub(crate) fn source<'a>(p: &'a Value, cmd: &str) -> Result<Source<'a>> {
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let src = source(p, "document.open")?;
open_bytes_with(s, src.name, &src.bytes, src.path.map(str::to_string), p)
// A relative path is the file in the working directory: its folder is where relative links
// are found (a bare file name has no folder of its own).
let path = src.path.map(super::absolute_path);
open_bytes_with(s, path.as_deref().unwrap_or(src.name), &src.bytes, path.clone(), p)
}
pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
let src = source(p, "file.newFromTemplate")?;
open_template(s, src.name, &src.bytes, src.path)
let path = src.path.map(super::absolute_path);
open_template(s, path.as_deref().unwrap_or(src.name), &src.bytes, path.as_deref())
}
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
+22 -2
View File
@@ -674,7 +674,7 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel), Libraries panel libraries, flattener, PDF and print
/// presets (imported).
/// presets (imported), scripts (checked without running them) and plug-ins (installed).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS))
.chain(format_filters())
@@ -683,6 +683,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
.chain(std::iter::once(("Scripts", super::scripts::EXTS)))
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
@@ -766,6 +767,20 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
/// The first `max` bytes of the file at `path` (all of it when it is shorter). Anything but a
/// regular file (a folder, a pipe, a device) is refused, also through a link.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>> {
use std::io::Read;
may_read(path)?;
if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
return Err(EngineError::Other(format!("{path}: not a file")));
}
let mut out = vec![];
std::fs::File::open(path).and_then(|f| f.take(max as u64).read_to_end(&mut out)).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(out)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
may_write(path)?;
@@ -794,7 +809,7 @@ pub(crate) fn write_new_file(path: &str, bytes: &[u8]) -> Result<()> {
may_write(path)?;
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path).map_err(err)?;
let written = f.write_all(bytes).and_then(|()| f.sync_all());
let written = f.write_all(bytes).and_then(|()| vectorcraft_format::sync_file(&f));
drop(f);
written.map_err(|e| {
// Best effort: the partly written file is all there is to clean up.
@@ -944,6 +959,11 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn read_file_head(path: &str, _: usize) -> Result<Vec<u8>> {
Err(no_fs(path))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn write_file(path: &str, _: &[u8]) -> Result<()> {
Err(no_fs(path))
+3 -2
View File
@@ -159,9 +159,10 @@ fn open_exts_cover_every_readable_format() {
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(
filters.len(),
7 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, plug-ins"
8 + FORMATS.iter().filter(|f| f.read).count(),
"and swatch libraries, libraries, flattener, PDF and print presets, scripts, plug-ins"
);
assert!(filters.contains(&("Scripts", crate::cmd::scripts::EXTS)), "File › Open checks scripts");
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
}
+22 -5
View File
@@ -2,6 +2,7 @@
//! Bounding Box, Rasterize, Crop Image, Mask (an image), Create Trim Marks, Convert to Shape and
//! Artboards → Convert / Rearrange. (Live blends are in `live.rs`.)
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;
use serde_json::{Value, json};
@@ -209,7 +210,9 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
fn above(s: &mut Session, lock: bool) -> Result<Value> {
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let mut hits: Vec<NodeId> = vec![];
// `selected` holds each selected object's visual bounds, by layer, with the index there of its
// ancestor directly inside the layer.
let mut selected: BTreeMap<NodeId, Vec<(usize, Rect)>> = BTreeMap::new();
for id in &roots {
let Some(layer) = d.layer_of(*id) else { continue };
// The selected object's ancestor directly inside the layer.
@@ -218,12 +221,26 @@ fn above(s: &mut Session, lock: bool) -> Result<Value> {
let top = anc.get(pos + 1).copied().unwrap_or(*id);
let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue };
let Some((_, idx, _)) = d.position(top) else { continue };
for n in d.children(Some(layer)).into_iter().flatten().skip(idx + 1) {
if roots.contains(&n.id) || hits.contains(&n.id) {
selected.entry(layer).or_default().push((idx, b));
}
// The loop walks up each layer once. Each object that is not selected and overlaps a selected
// object below it is locked (Lock) or hidden (Hide).
let roots: HashSet<NodeId> = roots.into_iter().collect();
let mut hits: BTreeSet<NodeId> = BTreeSet::new();
for (layer, mut below) in selected {
below.sort_by_key(|(idx, _)| *idx);
let mut below = below.into_iter().peekable();
// `under` holds the bounds of the selected objects below the object at `i`.
let mut under: Vec<Rect> = vec![];
for (i, n) in d.children(Some(layer)).into_iter().flatten().enumerate() {
while let Some((_, b)) = below.next_if(|(idx, _)| *idx < i) {
under.push(b);
}
if under.is_empty() || roots.contains(&n.id) {
continue;
}
if n.visual_bounds().is_some_and(|nb| nb.intersect(b).area() > 0.0 || nb.contains(b.center())) {
hits.push(n.id);
if n.visual_bounds().is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) {
hits.insert(n.id);
}
}
}
+2
View File
@@ -64,6 +64,7 @@ pub mod printtiling;
pub mod rasterfx;
mod recolor;
pub mod recovery;
pub mod scripts;
mod select;
pub(crate) mod shaper;
pub(crate) mod slices;
@@ -278,6 +279,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(printpresets::specs());
v.extend(printtiling::specs());
v.extend(plugin::specs());
v.extend(scripts::specs());
v.extend(cut::specs());
v.extend(halftone::specs());
v.extend(perspgrid::specs());
+78 -8
View File
@@ -10,8 +10,10 @@
//!
//! Callers outside the app (the control channel, MCP, the CLI) are answered with a parameter error
//! for a key the description doesn't name ([`check_params`]): a misspelt `widht` no longer runs the
//! command with its default. [`Session::execute`] itself takes what it is given, so the journal of
//! an older version still replays.
//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
//! what it is given, so the journal of an older version still replays.
use serde_json::Value;
@@ -150,6 +152,12 @@ fn accepted(doc: &'static str, depth: usize) -> Option<Vec<&'static str>> {
Some(keys)
}
/// Does the description `doc` name `key` as a parameter (its own keys, and those of the commands
/// its `…` entries name)?
pub(crate) fn takes_key(doc: &'static str, key: &str) -> bool {
doc_keys(doc).is_ok_and(|parsed| parsed.keys.contains(&key)) || accepted(doc, 0).is_some_and(|keys| keys.contains(&key))
}
/// The edits (insert, delete, replace, swap of neighbours) that turn `a` into `b`, ignoring case.
fn distance(a: &str, b: &str) -> usize {
let (a, b): (Vec<char>, Vec<char>) = (a.chars().map(|c| c.to_ascii_lowercase()).collect(), b.chars().map(|c| c.to_ascii_lowercase()).collect());
@@ -183,15 +191,56 @@ pub fn check_keys(id: &str, doc: &'static str, params: &Value) -> Result<()> {
return Ok(());
}
let Some(keys) = accepted(doc, 0) else { return Ok(()) };
let Some(key) = given.keys().find(|k| !keys.contains(&k.as_str())) else { return Ok(()) };
Err(bad(
match given.keys().find(|k| !keys.contains(&k.as_str())) {
Some(key) => Err(unknown(id, key, &keys)),
None => Ok(()),
}
}
/// The parameter error of command `id` for `key`, which isn't one of `keys`: with the nearest of
/// them, or all of them.
fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
bad(
id,
match nearest(key, &keys) {
match nearest(key, keys) {
Some(near) => format!("unknown parameter `{key}`; did you mean `{near}`?"),
None if keys.is_empty() => format!("unknown parameter `{key}`; the command takes no parameters"),
None => format!("unknown parameter `{key}`; the command takes: {}", keys.join(", ")),
},
))
)
}
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
/// effect takes (those of its dialog defaults and of its params description), each of its
/// default's type. Numbers out of range pass: the effect clamps them when it draws. An effect
/// plug-in resolves its own against its manifest, and an unknown effect is the command's to report.
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
let Value::Object(given) = params else { return Ok(()) };
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
return Ok(());
}
let Some(info) = vectorcraft_render::effects::effect_info(effect) else { return Ok(()) };
let defaults = info.defaults.as_object();
let mut keys: Vec<&str> = defaults.into_iter().flat_map(|d| d.keys().map(String::as_str)).collect();
// A description that doesn't parse names no more keys (the tests rule that out).
for key in doc_keys(info.params).map(|d| d.keys).unwrap_or_default() {
if !keys.contains(&key) {
keys.push(key);
}
}
for (key, v) in given {
if !keys.contains(&key.as_str()) {
return Err(unknown(id, key, &keys));
}
match defaults.and_then(|d| d.get(key)) {
Some(Value::Number(_)) if !v.is_number() => return Err(bad(id, format!("`{key}` must be a number"))),
Some(Value::Bool(_)) if !v.is_boolean() => return Err(bad(id, format!("`{key}` must be true or false"))),
// (A colour is also taken as `[r, g, b]` or as a colour object.)
Some(Value::String(_)) if v.is_number() || v.is_boolean() => return Err(bad(id, format!("`{key}` must be a string"))),
_ => {}
}
}
Ok(())
}
/// Commands whose params aren't checked yet (#1442): they read keys in ways the audit of the
@@ -327,10 +376,16 @@ pub const UNCHECKED: &[&str] = &[
"webExport.settings",
];
/// [`check_keys`] for an engine command, the steps of a `command.batch` included. A command the
/// engine doesn't have passes: running it reports that.
/// [`check_keys`] for an engine command, the steps of a `command.batch` included, and
/// [`check_effect_params`] for the effect `effect.apply` adds. A command the engine doesn't have
/// passes: running it reports that.
pub fn check_params(id: &str, params: &Value) -> Result<()> {
let Some(spec) = find_command(id) else { return Ok(()) };
if let ("effect.apply" | "appearance.addEffect", Some(effect), Some(given)) =
(id, params.get("effect").or_else(|| params.get("id")).and_then(Value::as_str), params.get("params"))
{
check_effect_params(id, effect, given)?;
}
if UNCHECKED.contains(&id) {
return Ok(());
}
@@ -344,3 +399,18 @@ pub fn check_params(id: &str, params: &Value) -> Result<()> {
}
Ok(())
}
impl crate::Session {
/// [`check_params`], and [`check_effect_params`] for the effects `effect.setParams` edits in
/// this session's document (not as a step of a `command.batch`: what it edits is only there
/// once the steps before it ran).
pub fn check_params(&self, id: &str, params: &Value) -> Result<()> {
check_params(id, params)?;
if let ("effect.setParams", Some(given)) = (id, params.get("params")) {
for effect in super::effectcmd::set_params_effects(self, params) {
check_effect_params(id, &effect, given)?;
}
}
Ok(())
}
}
+58 -2
View File
@@ -4,7 +4,7 @@ use std::collections::BTreeSet;
use serde_json::{Value, json};
use vectorcraft_doc::{NodeId, NodeKind, Scaling};
use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath, Vec2};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
use super::create::anchor_from_json;
use super::pathops::Beside;
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Join",
["Object", "Path"],
Some("Cmd+J"),
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path",
"{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair)} join two open paths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",
has_selection,
join
),
@@ -300,6 +300,37 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
}
/// Merge each run of `picked` neighbours of `sp` that lie on one another into one anchor, which
/// keeps the first one's incoming handle and the last one's outgoing handle. Returns how many
/// anchors went.
fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
let same = |a: &Anchor, b: &Anchor| a.p.distance(b.p) < 1e-6;
let before = sp.anchors.len();
// The closing pair of a closed path first: taking the last anchor moves no other index.
if sp.closed
&& before > 2
&& picked(0)
&& picked(before - 1)
&& let [first, .., last] = sp.anchors.as_mut_slice()
&& same(first, last)
{
first.h_in = last.h_in;
sp.anchors.pop();
}
// Back to front, so the indices `picked` knows stay true for the anchors still to visit.
for i in (1..sp.anchors.len()).rev() {
if picked(i - 1)
&& picked(i)
&& let Some([a, b]) = sp.anchors.get_mut(i - 1..=i)
&& same(a, b)
{
a.h_out = b.h_out;
sp.anchors.remove(i);
}
}
before - sp.anchors.len()
}
fn join(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.join";
let ids = match ids_param(p, "ids") {
@@ -312,6 +343,31 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
}
None => selected_paths(s)?,
};
// Direct-selected anchors lying on one another along a path (two corners brought together by
// Average) become one anchor; the paths themselves are joined only when there are none.
if p.get("ids").is_none() {
let st = s.doc()?;
let mut merged = vec![];
for (id, picked) in &st.selection.anchors {
let Some(mut path) = st.doc.node(*id).and_then(|n| n.path_data()).cloned() else { continue };
let n: usize = path.subpaths.iter_mut().enumerate().map(|(si, sp)| merge_coincident(sp, |ai| picked.contains(&(si, ai)))).sum();
if n > 0 {
merged.push((*id, path));
}
}
if !merged.is_empty() {
let objects = st.selection.objects.clone();
s.edit("Join", |d, sel| {
for (id, path) in merged {
*path_mut(d, id)? = path;
}
// The anchor indices changed: the paths stay selected, whole.
sel.set(objects);
Ok(())
})?;
return ok();
}
}
// The ends to join, as (reverse the first path, reverse the second): the first path's end
// must come last, the second's first.
let ends = match p.get("ends") {
+11 -1
View File
@@ -383,10 +383,15 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
let mut styles: Vec<Style> = vec![];
let mut shapes: Vec<po::Shape> = vec![];
let mut names: Vec<Option<String>> = vec![];
let mut push = |mut path: PathData, rule: FillRule, st: Style, name: Option<String>| {
let mut push = |mut path: PathData, rule: FillRule, mut st: Style, name: Option<String>| {
if path.is_empty() {
return;
}
// Trim, Merge and Crop remove any strokes (and Merge then merges by fill alone), as the
// public Pathfinder documentation describes.
if matches!(op, PathfinderOp::Trim | PathfinderOp::Merge | PathfinderOp::Crop) {
st.appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_)));
}
if op == PathfinderOp::Divide && st.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if !f.paint.is_none())) {
// Filled open contours keep their implicit closing edge. A zero-area line
// stays a cutter; normalised area also catches zero-signed-area self crossings.
@@ -498,6 +503,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let ids = s.edit("Offset Path", |d, sel| {
let (mut out, mut above) = (vec![], vec![]);
let mut paths = false;
for id in &roots {
let Some(n) = d.node(*id).cloned() else { continue };
let mut lv = vec![];
@@ -505,6 +511,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
let mut made = vec![];
for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) {
let Some((path, rule)) = node_path(l) else { continue };
paths = true;
let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path };
let off = po::offset_path(&base, delta, join, miter);
if off.is_empty() {
@@ -527,6 +534,9 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
out.push(new.id);
above.push((*id, vec![new]));
}
if out.is_empty() && paths {
return Err(EngineError::Other("Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes)".into()));
}
if out.is_empty() {
return Err(EngineError::Other("Offset Path: select paths".into()));
}
+159
View File
@@ -0,0 +1,159 @@
//! `script.check`: a script written for the reference app (`.jsx`, `.jsxinc`, `.js`, or a compiled
//! `.jsxbin`) checked without running any of it, by `vectorcraft-scriptcheck`: what kind of file it
//! is, what it needs, its directives and includes, the platform features and document-model member
//! chains it uses, the menu commands it runs, what it does outside the document, and its problems.
//! With a path and `includes`, the files its `#include` lines name are read from inside its folder,
//! each through the same checks as every other file a command reads.
use serde_json::{Value, json};
use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report};
/// The ids a reply can hold, for the windows that show it.
pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS};
use super::*;
/// The extensions of the scripts File › Open shows a check for.
pub const EXTS: &[&str] = &["jsx", "jsxinc", "jsxbin"];
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
query "script.check",
"Check Script",
[],
None,
"{path? | data?: the script's text | dataBase64?, name?: the file's name (default: the path's; with a path, only the reply's name), includes?: false (with a path: also check the files its #include lines name, inside the script's folder; not on the web)} check a script written for the reference app (.jsx, .jsxinc, .js, or a compiled .jsxbin) without running any of it → {name, kind: script|compiled|module|notText|tooLarge, needs: [language|dom|files|menuCommands|dialogs|hostOnly], lines, targets: [{value, app, instance?, version?, locale?, line, file?}], directives: [{name, value, line, form: \"#\"|\"//@\", file?}], includes: [{value, line, status: checked|missing|notFollowed|cycle|notRead|limit|unreadable, path?, file?}], features: [{id, count, lines}], dom: {members: [{path, count}], types: [{name, count}]}, menuCommands: {strings: [{value, count, lines}], dynamic}, outside: [{id: filesWritten|filesOpened|network|nativeCode|otherApps|environment|unseenCode|preferences, count, lines, modes?}], problems: [{code, line?, file?, detail?}], truncated} (file: the included file it is in; lines: at most 20 of the script's own, while counts include the included files; at most 8 MiB a file and 32 MiB in all, 64 included files 16 deep, 1,024 paths looked up for them, 1,000 entries a list, text echoed cut to 120 characters)",
always,
check
)]
}
/// The folder a script is in, which its included files are read from.
struct Folder {
dir: std::path::PathBuf,
/// The folder with its links followed (none where the file system can't tell).
real: Option<std::path::PathBuf>,
}
impl Folder {
/// The folder of the script at `path`.
fn of(path: &str) -> Self {
let dir = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
Folder { real: std::fs::canonicalize(dir).ok(), dir: dir.to_path_buf() }
}
}
impl Loader for Folder {
fn load(&mut self, path: &str, max: usize) -> Loaded {
let file = self.dir.join(path);
// A link that leads out of the script's folder isn't followed.
if let (Some(real), Ok(target)) = (&self.real, std::fs::canonicalize(&file))
&& !target.starts_with(real)
{
return Loaded::Refused;
}
let file = file.to_string_lossy();
if crate::file_access::check_read(&file).is_err() {
return Loaded::Refused;
}
super::fileio::read_file_head(&file, max).map_or(Loaded::Missing, Loaded::Bytes)
}
}
/// The bytes the base64 text `b64` holds, up to `max` and a few more: only the text they take is
/// decoded.
fn decode_head(b64: &str, max: usize) -> Option<Vec<u8>> {
let chars = max.div_ceil(3).saturating_mul(4);
let mut kept = 0usize;
let end = b64
.bytes()
.position(|b| {
kept += usize::from(!b.is_ascii_whitespace());
kept > chars
})
.unwrap_or(b64.len());
vectorcraft_format::base64_decode(b64.get(..end)?)
}
fn check(_: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "script.check";
let limits = Limits::default();
// One byte more than a file may have shows that the file is too large.
let max = limits.file_bytes.saturating_add(1);
let path = str_param(p, "path");
let bytes: std::borrow::Cow<[u8]> = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => super::fileio::read_file_head(path, max)?.into(),
(None, Some(text), _) => text.as_bytes().get(..max).unwrap_or(text.as_bytes()).into(),
(None, None, Some(b64)) => decode_head(b64, max).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
};
let name = super::fileio::file_name(str_param(p, "name").or(path).unwrap_or_default());
// With a path, the file is checked under its own name, which its includes are followed from;
// `name` names the reply.
let file = path.map(super::fileio::file_name).unwrap_or_else(|| name.clone());
let mut folder = path.filter(|_| bool_or(p, "includes", false)).map(Folder::of);
let report = vectorcraft_scriptcheck::check(&file, &bytes, folder.as_mut().map(|f| f as &mut dyn Loader), &limits);
Ok(report_json(&name, &report))
}
/// Object `v` with `key: value` added when there is a value.
fn with(mut v: Value, key: &str, value: Option<impl serde::Serialize>) -> Value {
if let (Some(o), Some(x)) = (v.as_object_mut(), value) {
o.insert(key.into(), json!(x));
}
v
}
/// The reply for report `r` on the script `name`.
fn report_json(name: &str, r: &Report) -> Value {
let targets: Vec<Value> = r
.targets
.iter()
.map(|t| {
let v = json!({"value": t.value, "app": t.app, "line": t.line});
let v = with(v, "instance", t.instance.as_deref());
let v = with(v, "version", t.version.as_deref());
let v = with(v, "locale", t.locale.as_deref());
with(v, "file", t.file.as_deref())
})
.collect();
let directives: Vec<Value> = r
.directives
.iter()
.map(|d| with(json!({"name": d.name, "value": d.value, "line": d.line, "form": d.form}), "file", d.file.as_deref()))
.collect();
let includes: Vec<Value> = r
.includes
.iter()
.map(|i| with(with(json!({"value": i.value, "line": i.line, "status": i.status.id()}), "path", i.path.as_deref()), "file", i.file.as_deref()))
.collect();
let features: Vec<Value> = r.features.iter().map(|f| json!({"id": f.id, "count": f.count, "lines": f.lines})).collect();
let members: Vec<Value> = r.members.iter().map(|m| json!({"path": m.name, "count": m.count})).collect();
let types: Vec<Value> = r.types.iter().map(|t| json!({"name": t.name, "count": t.count})).collect();
let strings: Vec<Value> = r.menu_strings.iter().map(|s| json!({"value": s.value, "count": s.count, "lines": s.lines})).collect();
let outside: Vec<Value> = r
.outside
.iter()
.map(|o| with(json!({"id": o.id, "count": o.count, "lines": o.lines}), "modes", Some(&o.modes).filter(|m| !m.is_empty())))
.collect();
let problems: Vec<Value> = r
.problems
.iter()
.map(|p| with(with(with(json!({"code": p.code}), "line", p.line), "file", p.file.as_deref()), "detail", p.detail.as_deref()))
.collect();
json!({
"name": name,
"kind": r.kind.id(),
"needs": r.needs.iter().map(|n| n.id()).collect::<Vec<_>>(),
"lines": r.lines,
"targets": targets,
"directives": directives,
"includes": includes,
"features": features,
"dom": {"members": members, "types": types},
"menuCommands": {"strings": strings, "dynamic": r.menu_dynamic},
"outside": outside,
"problems": problems,
"truncated": r.truncated,
})
}
+6 -1
View File
@@ -230,7 +230,12 @@ pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect();
if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) {
return Err(bad(cmd, format!("unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh; prefix - to turn off)")));
return Err(bad(
cmd,
format!(
"unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs; prefix - to turn off)"
),
));
}
Ok(Some(vectorcraft_text::OtFeatures::default().with_tags(tags).to_tags()))
}
+45 -2
View File
@@ -578,6 +578,11 @@ fn strip_replay_only(id: &str, p: &mut Value) {
}
}
/// Is `id` a path made into a guide (View → Guides → Make Guides)?
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, .. }))
}
/// 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 {
@@ -1295,16 +1300,35 @@ impl Session {
true
}
/// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back.
fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec<NodeId>) -> std::result::Result<(), String> {
let Some(d) = self.active_mut() else { return (spec.enabled)(self) };
let old = std::mem::replace(&mut d.selection, Selection { objects: ids, ..Selection::default() });
let r = (spec.enabled)(self);
if let Some(d) = self.active_mut() {
d.selection = old;
}
r
}
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
// The crate's own tests are the audit of the params descriptions (#1442): a key a test
// passes that its command's description doesn't name fails the test.
#[cfg(test)]
cmd::params::check_params(id, params)?;
self.check_params(id, params)?;
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
// document. The command validates those targets before making any edit.
let enabled = if id == "edit.clear" && params.get("ids").is_some() { cmd::has_doc(self) } else { (spec.enabled)(self) };
let enabled = if id == "edit.clear" && params.get("ids").is_some() {
cmd::has_doc(self)
} else if let Some(ids) = given_ids(spec.params, params) {
// Objects given as `ids` stand in for the selection: the command is enabled as it is
// with them selected (#1575).
self.enabled_with_selection(spec, ids)
} else {
(spec.enabled)(self)
};
if let Err(why) = enabled {
return Err(EngineError::Disabled(id.to_string(), why));
}
@@ -1482,10 +1506,18 @@ impl Session {
/// Change only the selection (not an undo step).
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
self.active_appearance_item = None;
let guides_locked = self.menu.guides_locked;
let st = self.doc_mut()?;
let before = st.selection.objects.clone();
f(&st.doc, &mut st.selection);
st.selection.prune(&st.doc);
// Lock Guides locks the paths made into guides too (#1534), whatever selects them.
if guides_locked {
let locked: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| is_guide_path(&st.doc, *id)).collect();
for id in locked {
st.selection.remove(id);
}
}
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
// the reference app: new art then goes beside it.
if st.selection.objects != before
@@ -1757,6 +1789,8 @@ mod tests_halftone;
#[cfg(test)]
mod tests_history_mem;
#[cfg(test)]
mod tests_ids_without_selection;
#[cfg(test)]
mod tests_inline;
#[cfg(test)]
mod tests_journal;
@@ -1883,6 +1917,8 @@ mod tests_saveoptions;
#[cfg(test)]
mod tests_scalestrokes;
#[cfg(test)]
mod tests_scripts;
#[cfg(test)]
mod tests_shaper;
#[cfg(test)]
mod tests_slices;
@@ -1942,3 +1978,10 @@ mod tests_widthprofiles;
mod tests_widthtool;
#[cfg(test)]
mod tests_xform;
/// The non-empty `ids` given to a command whose params description names `ids` as a key (the
/// key callers outside the app may pass it, [`cmd::params::takes_key`]).
fn given_ids(description: &'static str, params: &Value) -> Option<Vec<NodeId>> {
let ids = cmd::ids_param(params, "ids").filter(|ids| !ids.is_empty())?;
cmd::params::takes_key(description, "ids").then_some(ids)
}
+104 -1
View File
@@ -671,6 +671,108 @@ fn image_trace_make_places_paths_over_the_image() {
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
}
/// Shapes: the disc of the test image comes out as a live ellipse where the image lies, the same
/// circle as its traced path; without the option it is a plain path.
#[test]
fn image_trace_shapes_makes_live_shapes() {
let mut s = session();
image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true, "shapes": true}})).unwrap();
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
let paths = traced_paths(&n);
assert_eq!(paths.len(), 1);
let NodeKind::Path { path, live: Some(live), .. } = &paths[0].kind else { panic!("not a live shape: {:?}", paths[0].kind) };
let vectorcraft_doc::LiveShape::Ellipse { w, h, .. } = **live else { panic!("{live:?}") };
// A circle of r = 30 px, drawn twice the size.
assert!(w == h && (w - 60.0).abs() < 0.75, "{w} × {h}");
let bb = path.bounds().unwrap();
assert!((bb.center().x - 150.0).abs() < 1.5 && (bb.center().y - 150.0).abs() < 1.5 && (bb.width() - 120.0).abs() < 1.5, "{bb:?}");
assert_eq!(*path, live.to_path());
assert_eq!(paths[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 0)));
// Off, as documents saved before have it: a path.
let mut s = session();
image_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"params": {"ignoreWhite": true}})).unwrap();
let n = node(&s, NodeId(r["id"].as_u64().unwrap()));
assert!(matches!(traced_paths(&n)[0].kind, NodeKind::Path { live: None, .. }));
}
/// Auto Grouping: a ring and the dot in its hole are a group (the ring first), the square beside
/// them is not in it; the picture is the same as without, and the group traces again, expands
/// and releases as any other.
#[test]
fn image_trace_auto_grouping_groups_what_is_inside() {
let art = vectorcraft_trace::Raster::from_fn(120, 80, |x, y| {
let d = (x as f64 + 0.5 - 40.0).hypot(y as f64 + 0.5 - 40.0);
if d <= 6.0 || (90..110).contains(&x) && (30..50).contains(&y) {
[200, 0, 0, 255]
} else if (16.0..=30.0).contains(&d) {
[0, 0, 160, 255]
} else {
[255, 255, 255, 255]
}
});
let make = |grouping: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::IDENTITY);
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true, "autoGrouping": grouping}})).unwrap();
assert_eq!(r["paths"], 3);
let g = NodeId(r["id"].as_u64().unwrap());
(s, g)
};
let (mut s, g) = make(true);
let items = traced_paths(&node(&s, g));
assert_eq!(items.len(), 2);
let group = items.iter().find(|n| matches!(n.kind, NodeKind::Group { .. })).expect("a group");
let inside = group.children().unwrap();
assert_eq!(inside.len(), 2);
assert_eq!(inside[0].appearance.fill_paint(), Paint::solid(Color::rgb8(0, 0, 160)));
assert_eq!(inside[1].appearance.fill_paint(), Paint::solid(Color::rgb8(200, 0, 0)));
assert!(matches!(items.iter().find(|n| n.id != group.id).unwrap().kind, NodeKind::Path { .. }));
// The same picture as without.
let (flat, fg) = make(false);
assert_eq!(traced_paths(&node(&flat, fg)).len(), 3);
let shot = |s: &Session| {
let img = render_doc(s, true);
(0..120).flat_map(|x| (0..80).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).collect::<Vec<_>>()
};
assert_eq!(shot(&s), shot(&flat));
// Traced again without the option, it is flat again; expanded, the groups stay.
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true}})).unwrap();
assert_eq!(traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap()))).len(), 3);
s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"ignoreWhite": true, "autoGrouping": true}})).unwrap();
let r = s.execute("imageTrace.expand", &json!({})).unwrap();
let expanded = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
assert_eq!(expanded.children().unwrap().len(), 2);
}
/// Gradients: an image that is one linear gradient is one path filled with it, the gradient's line
/// where the image lies; without the option it is bands of flat colour, as saved documents have it.
#[test]
fn image_trace_gradients_fills_with_a_gradient() {
let art = vectorcraft_trace::Raster::from_fn(100, 60, |x, _| {
let t = (f64::from(x) + 0.5) / 100.0;
[(20.0 + 200.0 * t) as u8, 40, (220.0 - 180.0 * t) as u8, 255]
});
let make = |gradients: bool| {
let mut s = session();
add_image(&mut s, &art, Affine::translate((50.0, 50.0)) * Affine::scale(2.0));
let r = s.execute("imageTrace.make", &json!({"preset": "6 Colors", "params": {"gradients": gradients}})).unwrap();
traced_paths(&node(&s, NodeId(r["id"].as_u64().unwrap())))
};
let paths = make(true);
assert_eq!(paths.len(), 1);
let Paint::Gradient(g) = paths[0].appearance.fill_paint() else { panic!("{:?}", paths[0].appearance.fill_paint()) };
assert_eq!(g.gradient.kind, vectorcraft_color::GradientKind::Linear);
assert_eq!(g.gradient.stops.len(), 2);
// Level, from the image's left edge (x = 50) to its right one (x = 250), at any height.
let geom = g.geom.as_ref().unwrap();
let (a, b) = if geom.start.x < geom.end.x { (geom.start, geom.end) } else { (geom.end, geom.start) };
assert!((a.x - 50.0).abs() < 3.0 && (b.x - 250.0).abs() < 3.0 && (a.y - b.y).abs() < 2.0, "{geom:?}");
let flat = make(false);
assert!(flat.len() > 1 && flat.iter().all(|n| matches!(n.appearance.fill_paint(), Paint::Solid { .. })));
}
#[test]
fn image_trace_release_and_expand() {
let mut s = session();
@@ -795,7 +897,8 @@ fn image_trace_presets_query_and_errors() {
let mut s = session();
let r = s.execute("imageTrace.presets", &json!({})).unwrap();
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!(names.len(), 13);
assert_eq!(names.len(), 15);
assert!(names.contains(&"Auto Color") && names.contains(&"Outline"));
assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray") && names.contains(&"Flat Logo"));
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert!(s.execute("imageTrace.make", &json!({"id": a.0})).is_err(), "not an image");
+48
View File
@@ -412,6 +412,29 @@ fn smooth_tool_reduces_jagged_path() {
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0));
}
/// #1512: a short drag over a small bump smooths the stretch of path under it. The long straight
/// sides next to the bump stay straight lines from their far ends, which keep their place.
#[test]
fn a_short_smooth_drag_leaves_the_rest_of_long_segments_alone() {
let mut s = session();
let anchors: Vec<_> =
[(50.0, 200.0), (190.0, 200.0), (200.0, 192.0), (210.0, 200.0), (350.0, 200.0)].map(|(x, y)| json!({"x": x, "y": y})).to_vec();
let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let before = path(&s, id);
drag(&mut s, "smooth", &[(195.0, 194.0), (205.0, 194.0)], Mods::default());
let p = path(&s, id);
assert_ne!(p, before, "the bump is smoothed");
let a = &p.subpaths[0].anchors;
let (first, second, last) = (a[0], a[1], a[a.len() - 1]);
assert!(first.p == Point::new(50.0, 200.0) && !first.has_out() && !second.has_in(), "the left side is a line still: {first:?} {second:?}");
assert!(last.p == Point::new(350.0, 200.0) && !last.has_in() && !a[a.len() - 2].has_out(), "and the right one: {last:?}");
// Every other anchor is within the tool's reach of the drag (12 px either way).
for x in &a[1..a.len() - 1] {
assert!((182.0..=218.0).contains(&x.p.x) && (190.0..=201.0).contains(&x.p.y), "{x:?}");
}
}
#[test]
fn join_tool_joins_two_lines() {
let mut s = session();
@@ -1141,6 +1164,31 @@ fn the_pen_continues_any_open_path_and_joins_another() {
assert_eq!(pts(&s, a), [(400.0, 200.0), (300.0, 200.0), (200.0, 100.0), (100.0, 100.0), (50.0, 50.0)]);
}
/// #1533: two corners of a rectangle brought together by Average become one anchor on Join, in
/// one undo step; anchors that aren't on one another leave Join as it was.
#[test]
fn join_merges_the_selected_anchors_average_brought_together() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 100.0, 80.0);
let pts = |s: &Session| path(s, r).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect::<Vec<_>>();
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap();
assert!(s.execute("path.join", &json!({})).is_err(), "apart: nothing to merge, and the path is closed");
s.execute("path.average", &json!({"axis": "both"})).unwrap();
s.execute("path.join", &json!({})).unwrap();
assert_eq!(pts(&s), [(150.0, 100.0), (200.0, 180.0), (100.0, 180.0)]);
assert!(path(&s, r).subpaths[0].closed);
let sel = &s.doc().unwrap().selection;
assert!(sel.objects == [r] && sel.anchors.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pts(&s).len(), 4);
// The closing pair: the first and last anchors of a closed path.
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0], [0, 3]], "mode": "set"})).unwrap();
s.execute("path.average", &json!({"axis": "both"})).unwrap();
let before = pts(&s);
s.execute("path.join", &json!({})).unwrap();
assert_eq!(pts(&s), before[..3]);
}
/// `path.join {ids, ends}` joins the ends asked for, not the nearest pair; bad requests are errors.
#[test]
fn join_takes_the_paths_and_the_ends_to_join() {
@@ -0,0 +1,83 @@
//! #1575: commands that take `ids` work on the objects given, with nothing selected; the
//! selection is left as it was.
use serde_json::json;
use super::*;
fn doc_with_rect() -> (Session, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("select.none", &json!({})).unwrap();
(s, id)
}
#[test]
fn ids_stand_in_for_the_selection() {
for (cmd, params) in [
("object.repeat.radial", json!({})),
("edit.colors.invert", json!({})),
("object.pattern.make", json!({"name": "P"})),
("symbol.new", json!({"name": "S"})),
("object.mesh.create", json!({})),
("object.makePixelPerfect", json!({})),
] {
let (mut s, id) = doc_with_rect();
let mut p = params;
p["ids"] = json!([id]);
let r = s.execute(cmd, &p);
assert!(r.is_ok(), "{cmd}: {r:?}");
}
}
#[test]
fn the_selection_is_put_back_and_the_check_still_applies() {
let (mut s, id) = doc_with_rect();
// Disabled even with the object given: Make with Top Object needs two.
let r = s.execute("object.envelope.makeWithTopObject", &json!({"ids": [id]}));
assert!(matches!(r, Err(EngineError::Disabled(..))), "{r:?}");
assert!(s.doc().unwrap().selection.objects.is_empty(), "the stand-in selection is put back");
// Without ids, nothing selected is still nothing selected.
let r = s.execute("object.repeat.radial", &json!({}));
assert!(matches!(r, Err(EngineError::Disabled(..))), "{r:?}");
}
#[test]
fn only_an_ids_key_counts() {
let p = json!({"ids": [3]});
assert_eq!(given_ids("{ids?: [id]}", &p), Some(vec![NodeId(3)]));
assert_eq!(given_ids("{id?|ids?, name}", &p), Some(vec![NodeId(3)]));
assert_eq!(given_ids("{grids: n, guides: bool}", &p), None);
// A word in what the description says about another key isn't the key.
assert_eq!(given_ids("{name: of the swatch the ids of the selection take}", &p), None);
assert_eq!(given_ids("{ids?: [id]}", &json!({"ids": []})), None);
}
/// Every command that names `ids` and is refused with nothing selected: given the object as `ids`
/// it does what it does with the object selected (the same result or the same error, and the same
/// document), so no command is let through to run on an empty selection.
#[test]
fn ids_do_what_the_selection_does_in_every_command_that_takes_them() {
let mut checked = vec![];
for spec in command_specs() {
let (mut given, id) = doc_with_rect();
if !cmd::params::takes_key(spec.params, "ids") || (spec.enabled)(&given).is_ok() {
continue;
}
let (mut selected, _) = doc_with_rect();
selected.execute("select.set", &json!({"ids": [id]})).unwrap();
let (a, b) = (given.execute(spec.id, &json!({"ids": [id]})), selected.execute(spec.id, &json!({})));
match (&a, &b) {
(Ok(_), Ok(_)) => {}
(Err(x), Err(y)) => assert_eq!(x.to_string(), y.to_string(), "{}", spec.id),
_ => panic!("{}: with ids {a:?}, with the selection {b:?}", spec.id),
}
match (given.doc(), selected.doc()) {
(Ok(g), Ok(s)) => assert!(g.doc == s.doc, "{}: the documents differ", spec.id),
(g, s) => assert_eq!(g.is_ok(), s.is_ok(), "{}", spec.id),
}
checked.push((spec.id, a.is_ok()));
}
assert!(checked.iter().filter(|c| c.1).count() >= 20, "{checked:?}");
}
+5 -5
View File
@@ -22,13 +22,13 @@ fn square_at(k: usize) -> Rect {
}
/// Square `k` ([`square_at`]) as a path with a new id.
fn square(d: &mut Document, k: usize) -> Node {
pub(crate) fn square(d: &mut Document, k: usize) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default_art())
}
/// A session whose document holds `make(d, k)` for each `k` below `n` on its one layer, all of it
/// selected.
fn session(n: usize, mut make: impl FnMut(&mut Document, usize) -> Node) -> Session {
pub(crate) fn session(n: usize, mut make: impl FnMut(&mut Document, usize) -> Node) -> Session {
let mut d = Document::new(ROW as f64 * 20.0, (n / ROW + 1) as f64 * 20.0);
let art: Vec<Arc<Node>> = (0..n).map(|k| Arc::new(make(&mut d, k))).collect();
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
@@ -46,7 +46,7 @@ fn ids_of(n: &Node) -> Vec<NodeId> {
}
/// The ids of the objects on layer `i`.
fn layer(s: &Session, i: usize) -> Vec<NodeId> {
pub(crate) fn layer(s: &Session, i: usize) -> Vec<NodeId> {
ids_of(&s.doc().unwrap().doc.layers[i])
}
@@ -76,7 +76,7 @@ fn soon<T: Send + 'static>(mut s: Session, what: &'static str, f: impl FnOnce(&m
}
/// Runs command `cmd` with `params` through [`soon`] and returns its result.
fn run_soon(s: Session, cmd: &'static str, params: Value) -> (Session, Value) {
pub(crate) fn run_soon(s: Session, cmd: &'static str, params: Value) -> (Session, Value) {
let (s, r) = soon(s, cmd, move |s| s.execute(cmd, &params));
(s, r.unwrap())
}
@@ -87,7 +87,7 @@ fn ids_in(r: &Value) -> Vec<NodeId> {
}
/// Runs Undo and checks that it gives back `before`.
fn undo_gives_back(s: &mut Session, before: &Document) {
pub(crate) fn undo_gives_back(s: &mut Session, before: &Document) {
s.execute("edit.undo", &json!({})).unwrap();
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
}
+117 -2
View File
@@ -2,9 +2,10 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ColorMode, LiveShape, Node, NodeKind, TextKind};
use vectorcraft_geom::Rect;
use vectorcraft_doc::{Appearance, ColorMode, LiveShape, Node, NodeKind, TextKind};
use vectorcraft_geom::{Rect, shapes};
use super::tests_large_edits::{layer, run_soon, square, undo_gives_back};
use super::*;
fn session() -> Session {
@@ -137,6 +138,100 @@ fn hide_all_artwork_above_deselects_the_objects_it_hides() {
assert_eq!((selected(&s), key(&s)), (vec![hidden, a, b], Some(a)));
}
/// Lock › All Artwork Above and Hide › All Artwork Above take each object that overlaps a selected
/// object below it in the same layer, once, whether it is locked or hidden already or not. An object
/// that overlaps only a selected object above it, the group around a selected object, the selected
/// objects and an object that lies over a selected object of another layer stay as they are.
#[test]
fn lock_and_hide_all_artwork_above_take_what_overlaps_a_selected_object_below_in_its_layer() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
// `b` overlaps only `c`, which lies above it.
let b = rect(&mut s, 250.0, 0.0, 100.0, 100.0);
let c = rect(&mut s, 90.0, 0.0, 200.0, 100.0);
// `d` overlaps `a`, `e` overlaps `c`, `f` overlaps both, `g` overlaps neither, and `k` overlaps `a` and is locked.
let d = rect(&mut s, 20.0, 50.0, 30.0, 30.0);
let e = rect(&mut s, 200.0, 50.0, 30.0, 30.0);
let f = rect(&mut s, 60.0, 10.0, 50.0, 20.0);
let g = rect(&mut s, 500.0, 500.0, 20.0, 20.0);
let k = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
s.execute("layer.setProps", &json!({"id": k.0, "locked": true})).unwrap();
// `m` is in a group, and `n` overlaps it.
let m = rect(&mut s, 800.0, 0.0, 50.0, 50.0);
sel(&mut s, &[m]);
let group = id_of(&s.execute("object.group", &json!({})).unwrap());
let n = rect(&mut s, 820.0, 20.0, 50.0, 50.0);
// In another layer, `h` lies where `a` lies, and `j` overlaps `i`.
s.execute("layer.new", &json!({})).unwrap();
let h = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let i = rect(&mut s, 600.0, 0.0, 100.0, 100.0);
let j = rect(&mut s, 650.0, 50.0, 100.0, 100.0);
let (taken, kept) = ([d, e, f, k, n, j], [a, b, c, g, group, m, h, i]);
for (cmd, lock) in [("object.lock.above", true), ("object.hide.above", false)] {
let done = |s: &Session, id: NodeId| if lock { node(s, id).locked } else { !node(s, id).visible };
sel(&mut s, &[a, c, m, i]);
let steps = undo_len(&s);
assert_eq!(s.execute(cmd, &json!({})).unwrap(), json!({"count": 6}), "{cmd}");
assert!(taken.iter().all(|id| done(&s, *id)), "{cmd}");
assert!(!kept.iter().any(|id| done(&s, *id)), "{cmd}");
assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, c, m, i]), "{cmd}");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// A session with 1,000 tiles of 100 x 200 pt in rows of 40 under the 50,000 squares of [`square`],
/// 50 on each tile, all of it selected; and the ids of the tiles and of the squares.
fn tiles_under_squares() -> (Session, Vec<NodeId>, Vec<NodeId>) {
let tile = |t: usize| {
let (x, y) = ((t % 40) as f64 * 100.0, (t / 40) as f64 * 200.0);
Rect::new(x, y, x + 100.0, y + 200.0)
};
let s = super::tests_large_edits::session(51_000, |d, k| {
if k < 1000 { Node::path(d.alloc_id(), shapes::rectangle(tile(k)), Appearance::default_art()) } else { square(d, k - 1000) }
});
let mut tiles = layer(&s, 0);
let squares = tiles.split_off(1000);
(s, tiles, squares)
}
/// With 1,000 tiles selected under 50,000 squares, Lock › All Artwork Above locks every square in one
/// undo step and Hide › All Artwork Above hides every square, each within 20 s, and the tiles stay
/// selected.
#[test]
fn lock_and_hide_all_artwork_above_1000_tiles_under_50000_objects_finish_within_20_s() {
let (mut s, tiles, squares) = tiles_under_squares();
sel(&mut s, &tiles);
let before = s.doc().unwrap().doc.clone();
let (mut s, r) = run_soon(s, "object.lock.above", json!({}));
assert_eq!(r, json!({"count": 50_000}));
let d = &s.doc().unwrap().doc;
assert!(squares.iter().all(|id| d.node(*id).is_some_and(|n| n.locked)), "every square is locked");
assert!(tiles.iter().all(|id| d.node(*id).is_some_and(|n| !n.locked)), "no tile is locked");
assert!(selected(&s) == tiles, "the tiles stay selected");
undo_gives_back(&mut s, &before);
let (s, r) = run_soon(s, "object.hide.above", json!({}));
assert_eq!(r, json!({"count": 50_000}));
let d = &s.doc().unwrap().doc;
assert!(squares.iter().all(|id| d.node(*id).is_some_and(|n| !n.visible)), "every square is hidden");
assert!(tiles.iter().all(|id| d.node(*id).is_some_and(|n| n.visible)), "no tile is hidden");
assert!(selected(&s) == tiles, "the tiles stay selected");
}
/// With all 51,000 objects of [`tiles_under_squares`] selected, Lock › All Artwork Above and Hide ›
/// All Artwork Above each find nothing to lock or hide within 20 s and record no undo step.
#[test]
fn lock_and_hide_all_artwork_above_with_51000_objects_selected_finish_within_20_s() {
let (s, tiles, squares) = tiles_under_squares();
let (steps, all) = (undo_len(&s), [tiles, squares].concat());
assert!(selected(&s) == all, "everything is selected");
let (s, r) = run_soon(s, "object.lock.above", json!({}));
assert_eq!(r, json!({"count": 0}));
let (s, r) = run_soon(s, "object.hide.above", json!({}));
assert_eq!(r, json!({"count": 0}));
assert_eq!(undo_len(&s), steps);
assert!(selected(&s) == all, "everything stays selected");
}
#[test]
fn lock_and_hide_other_layers() {
let mut s = session();
@@ -1249,6 +1344,26 @@ fn ruler_guides_and_lock() {
assert!(s.execute("guide.remove", &json!({"index": 0})).is_err());
}
/// #1534: Lock Guides locks the paths made into guides too: they can't be selected (so neither
/// moved, reshaped nor deleted), by a click, a marquee or Select All, and locking lets them go.
#[test]
fn locked_guides_lock_guide_paths() {
let mut s = session();
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 20.0, 10.0, 10.0));
sel(&mut s, &[a]);
s.execute("view.guides.make", &json!({})).unwrap();
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
sel(&mut s, &[a, b]);
assert_eq!(selected(&s), vec![b]);
s.execute("select.all", &json!({})).unwrap();
assert_eq!(selected(&s), vec![b]);
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
sel(&mut s, &[a, b]);
assert_eq!(selected(&s), vec![a, b]);
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
assert_eq!(selected(&s), vec![b]);
}
fn guides(s: &Session) -> Vec<(bool, f64)> {
s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect()
}
+45 -1
View File
@@ -2,7 +2,7 @@
use serde_json::json;
use crate::cmd::params::{UNCHECKED, check_keys, check_params, doc_keys};
use crate::cmd::params::{UNCHECKED, check_effect_params, check_keys, check_params, doc_keys};
use crate::{EngineError, Session, command_specs, find_command};
fn keys(doc: &str) -> Vec<&str> {
@@ -95,6 +95,50 @@ fn a_batch_checks_its_steps() {
assert_eq!(refusal("command.batch", json!({"steps": []})), "unknown parameter `steps`; the command takes: label, commands, artboard");
}
/// #1592: the `params` of an effect are held to the effect's own keys and types, where the effect
/// is named (`effect.apply`) and where the document says which it is (`effect.setParams`).
#[test]
fn effect_params_are_the_effects_own() {
use vectorcraft_render::effects::effect_catalog;
for e in effect_catalog() {
doc_keys(e.params).unwrap_or_else(|why| panic!("{}: {why}", e.id));
check_effect_params("effect.apply", e.id, &e.defaults).unwrap_or_else(|why| panic!("{}'s defaults: {why}", e.id));
}
let apply = |effect: &str, params| json!({"effect": effect, "params": params});
assert_eq!(refusal("effect.apply", apply("blur.gaussian", json!({"blurr": 3}))), "unknown parameter `blurr`; the command takes: radius");
assert_eq!(
refusal("appearance.addEffect", apply("stylize.dropShadow", json!({"opacty": 50}))),
"unknown parameter `opacty`; did you mean `opacity`?"
);
assert_eq!(refusal("effect.apply", apply("blur.gaussian", json!({"radius": "big"}))), "`radius` must be a number");
assert_eq!(refusal("effect.apply", apply("threeD.revolve", json!({"shade": 1}))), "`shade` must be true or false");
assert_eq!(refusal("effect.apply", apply("threeD.revolve", json!({"edge": 1}))), "`edge` must be a string");
// Out of range is the effect's to clamp; a key only its description names is one it takes;
// a plug-in's are its manifest's; an unknown effect is the command's to report.
for (effect, params) in [
("stylize.dropShadow", json!({"opacity": 500, "x": -3, "color": "#ff0000"})),
("stylize.dropShadow", json!({"color": [1, 0, 0]})),
("threeD.revolve", json!({"segments": 100000})),
("path.outlineStroke", json!({"width": 2})),
("plugin.anything", json!({"whatever": 1})),
("no.such", json!({"whatever": 1})),
] {
assert!(check_params("effect.apply", &apply(effect, params.clone())).is_ok(), "{effect} {params}");
}
// `effect.setParams` names an index: the session knows the effect there.
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
s.execute("effect.apply", &apply("blur.gaussian", json!({"radius": 2}))).unwrap();
for (params, why) in [(json!({"blurr": 3}), "unknown parameter `blurr`"), (json!({"radius": true}), "`radius` must be a number")] {
let e = s.execute("effect.setParams", &json!({"index": 0, "params": params})).unwrap_err().to_string();
assert!(e.contains(why), "{e}");
}
s.execute("effect.setParams", &json!({"index": 0, "params": {"radius": 1e9}, "visible": false})).unwrap();
// No effect at the index: the command says so, not the check.
assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok());
}
/// What a command notes for its journal entry is a key its description names, so the entry
/// passes the check a caller's params get.
#[test]
+37
View File
@@ -893,3 +893,40 @@ fn divide_keeps_implicitly_closed_filled_paths_including_zero_signed_area() {
assert!(pieces.iter().all(|n| n.appearance == source.appearance));
}
}
/// #1570: Trim, Merge and Crop remove the objects' strokes; Merge then merges same-coloured
/// fills whatever their strokes were.
#[test]
fn trim_merge_and_crop_remove_strokes() {
for op in ["trim", "merge", "crop"] {
let mut s = session();
let (a, b) = two_rects(&mut s);
for (id, c) in [(a, Color::rgb(0.0, 0.0, 0.0)), (b, Color::rgb(1.0, 0.0, 0.0))] {
s.edit("t", |d, _| {
d.node_mut(id).unwrap().appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::solid(c), 3.0).into();
Ok(())
})
.unwrap();
}
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = ids(&s.execute(&format!("object.pathfinder.{op}"), &json!({})).unwrap());
let g = node(&s, r[0]);
let pieces = g.children().unwrap();
assert!(!pieces.is_empty(), "{op}");
assert!(pieces.iter().all(|c| c.appearance.stroke_width() == 0.0 && c.appearance.fill().is_some()), "{op} keeps a stroke");
if op == "merge" {
assert_eq!(pieces.len(), 1, "same fill, different strokes: merged");
}
}
}
/// #1590: an inset larger than the shape leaves nothing, and the error says so rather than asking
/// for paths that are selected.
#[test]
fn offset_path_that_leaves_nothing_says_so() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let e = s.execute("object.path.offsetPath", &json!({"offset": -20})).unwrap_err().to_string();
assert!(e.contains("leaves nothing") && !e.contains("select paths"), "{e}");
}
+12 -3
View File
@@ -106,10 +106,19 @@ fn listed_applied_edited_and_undone() {
assert_eq!((&e["raster"], &e["menu"]), (&json!(true), &json!(["Effect", menu])), "{id}");
}
}
// Junk parameters are stored as given and read clamped; the canvas still draws.
// Numbers far out of range are stored as given and read clamped; the canvas still draws.
for (id, _) in EFFECTS {
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": {"amount": 1e308, "radius": -1e308, "threshold": "x", "quality": 7}}))
.unwrap();
let defaults = vectorcraft_render::effects::default_params(id).unwrap();
let junk: serde_json::Map<String, Value> = defaults
.as_object()
.unwrap()
.iter()
.filter(|(_, v)| v.is_number())
.enumerate()
.map(|(i, (k, _))| (k.clone(), json!(if i % 2 == 0 { 1e308 } else { -1e308 })))
.collect();
assert!(!junk.is_empty(), "{id}");
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": junk})).unwrap();
}
let _ = render(&s);
let n = s.doc().unwrap().doc.node(NodeId(g)).unwrap().clone();
+171
View File
@@ -0,0 +1,171 @@
//! `script.check`: scripts written for the reference app checked without running them, from a path,
//! text or base64; the files their `#include` lines name read from the script's folder when asked,
//! through the automation read root; and the limits.
use serde_json::{Value, json};
use super::*;
fn check(p: Value) -> Value {
Session::new().execute("script.check", &p).unwrap_or_else(|e| panic!("script.check {p}: {e}"))
}
/// A fresh folder for the files of test `name`.
fn folder(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vc-script-check-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("scripts/lib")).unwrap();
dir
}
fn statuses(r: &Value) -> Vec<&str> {
r["includes"].as_array().unwrap().iter().map(|i| i["status"].as_str().unwrap()).collect()
}
#[test]
fn a_script_is_reported_without_running_it() {
let src = concat!(
"#target acme-12\n",
"var doc = app.activeDocument;\n",
"app.executeMenuCommand('acmecopyall');\n",
"var f = new File(p); f.open('w');\n",
"alert('done');\n",
);
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("script.check", &json!({"data": src, "name": "/Users/me/Tidy Layers.jsx"})).unwrap();
assert_eq!((r["name"].as_str(), r["kind"].as_str(), r["lines"].as_u64()), (Some("Tidy Layers.jsx"), Some("script"), Some(5)));
assert_eq!(r["needs"], json!(["language", "dom", "files", "menuCommands"]));
assert_eq!(r["targets"], json!([{"value": "acme-12", "app": "acme", "version": "12", "line": 1}]));
assert_eq!(r["directives"], json!([{"name": "target", "value": "acme-12", "line": 1, "form": "#"}]));
assert_eq!(r["menuCommands"], json!({"strings": [{"value": "acmecopyall", "count": 1, "lines": [3]}], "dynamic": 0}));
assert_eq!(r["outside"], json!([{"id": "filesWritten", "count": 1, "lines": [4], "modes": ["w"]}]));
assert_eq!(r["dom"], json!({"members": [{"path": "app.activeDocument", "count": 1}], "types": []}));
let notify = r["features"].as_array().unwrap().iter().find(|f| f["id"] == "notify").unwrap();
assert_eq!(notify, &json!({"id": "notify", "count": 1, "lines": [5]}));
assert_eq!((r["problems"].clone(), r["includes"].clone(), r["truncated"].clone()), (json!([]), json!([]), json!(false)));
// Nothing ran: the document has no new undo step, and the check isn't recorded.
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
assert!(!find_command("script.check").unwrap().journal);
}
#[test]
fn a_path_text_and_base64_give_one_report() {
let dir = folder("forms");
let src = "#include 'lib/x.jsxinc'\nvar s = 'open;\n";
let path = dir.join("scripts/Tidy.jsx");
std::fs::write(&path, src).unwrap();
let by_path = check(json!({"path": path.to_string_lossy()}));
assert_eq!(by_path["name"], "Tidy.jsx");
assert_eq!(by_path["problems"], json!([{"code": "unterminatedString", "line": 2}]));
assert_eq!(check(json!({"data": src, "name": "Tidy.jsx"})), by_path);
assert_eq!(check(json!({"dataBase64": vectorcraft_format::base64_encode(src.as_bytes()), "name": "Tidy.jsx"})), by_path);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn includes_are_read_from_the_scripts_folder_only_when_asked() {
let dir = folder("includes");
std::fs::write(dir.join("scripts/main.jsx"), "#include 'lib/net.jsxinc'\n#include '../outside.jsxinc'\n#include 'lib/none.jsxinc'\n").unwrap();
std::fs::write(dir.join("scripts/lib/net.jsxinc"), "var s = new Socket();\n").unwrap();
std::fs::write(dir.join("outside.jsxinc"), "alert('outside');\n").unwrap();
let path = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
let r = check(json!({"path": path}));
assert_eq!(statuses(&r), ["notRead", "notRead", "notRead"]);
assert_eq!(r["needs"], json!(["language"]));
let r = check(json!({"path": path, "includes": true}));
assert_eq!(statuses(&r), ["checked", "notFollowed", "missing"]);
assert_eq!(r["includes"][0]["path"], "lib/net.jsxinc");
assert_eq!(r["needs"], json!(["language", "hostOnly"]));
assert_eq!(r["outside"], json!([{"id": "network", "count": 1, "lines": []}]), "an included file's lines aren't the script's");
// Text has no folder to read from.
let r = check(json!({"data": "#include 'lib/net.jsxinc'\n", "includes": true}));
assert_eq!(statuses(&r), ["notRead"]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_name_given_with_a_path_names_the_reply_and_the_includes_follow_the_path() {
let dir = folder("named");
std::fs::write(dir.join("scripts/main.jsx"), "#include 'helper.jsx'\n").unwrap();
std::fs::write(dir.join("scripts/helper.jsx"), "var s = new Socket();\n").unwrap();
let path = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
let r = check(json!({"path": path, "name": "helper.jsx", "includes": true}));
assert_eq!(r["name"], "helper.jsx");
assert_eq!(statuses(&r), ["checked"]);
assert_eq!(r["needs"], json!(["language", "hostOnly"]));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn links_out_of_the_folder_and_the_automation_read_root_keep_reads_inside() {
let dir = folder("root");
std::fs::write(dir.join("scripts/main.jsx"), "#include 'lib/link.jsxinc'\n#include 'lib/ok.jsxinc'\n").unwrap();
std::fs::write(dir.join("scripts/lib/ok.jsxinc"), "alert(1);\n").unwrap();
std::fs::write(dir.join("secret.jsxinc"), "var s = new Socket();\n").unwrap();
std::os::unix::fs::symlink(dir.join("secret.jsxinc"), dir.join("scripts/lib/link.jsxinc")).unwrap();
std::fs::write(dir.join("other.jsx"), "alert(2);\n").unwrap();
let main = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
// A link that leads out of the script's folder isn't followed.
let r = check(json!({"path": main, "includes": true}));
assert_eq!(statuses(&r), ["notFollowed", "checked"]);
assert_eq!(r["outside"], json!([]));
// Under a read root, the script and its includes are read inside it, and a script outside isn't.
let roots = crate::file_access::AutomationRoots::new(Some(&dir.join("scripts")), None).unwrap().unwrap();
let mut s = Session::new();
let r = crate::file_access::confine(Some(&roots), || s.execute("script.check", &json!({"path": main, "includes": true}))).unwrap();
assert_eq!(statuses(&r), ["notFollowed", "checked"]);
let other = dir.join("other.jsx").to_string_lossy().into_owned();
let e = crate::file_access::confine(Some(&roots), || s.execute("script.check", &json!({"path": other}))).unwrap_err();
assert!(e.to_string().contains("outside the read root"), "{e}");
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn pipes_and_folders_are_not_read() {
let dir = folder("pipes");
let pipe = dir.join("scripts/lib/pipe.jsxinc");
assert!(std::process::Command::new("mkfifo").arg(&pipe).status().unwrap().success());
std::fs::write(dir.join("scripts/main.jsx"), "#include 'lib/pipe.jsxinc'\n#include 'lib'\n").unwrap();
let main = dir.join("scripts/main.jsx").to_string_lossy().into_owned();
let pipe = pipe.to_string_lossy().into_owned();
// Opening a pipe that has no writer waits for one: the checks run on a thread with a time limit.
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut s = Session::new();
let included = s.execute("script.check", &json!({"path": main, "includes": true})).map(|r| r["includes"].clone());
let direct = s.execute("script.check", &json!({ "path": pipe })).map_err(|e| e.to_string());
let _ = tx.send((included.map_err(|e| e.to_string()), direct));
});
let (included, direct) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("the checks return");
let statuses: Vec<String> = included.unwrap().as_array().unwrap().iter().map(|i| i["status"].as_str().unwrap().to_string()).collect();
assert_eq!(statuses, ["missing", "missing"]);
assert!(direct.is_err_and(|e| e.contains("not a file")));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn kinds_limits_and_bad_params() {
let kind = |p: Value| check(p)["kind"].as_str().unwrap().to_string();
assert_eq!(kind(json!({"data": "@JSXBIN@ES@2.0@AcmeBody", "name": "a.jsx"})), "compiled");
assert_eq!(kind(json!({"data": "alert(1);", "name": "a.jsxbin"})), "compiled");
assert_eq!(kind(json!({"data": "import a from 'a';\nexport default a;\n"})), "module");
assert_eq!(kind(json!({"dataBase64": vectorcraft_format::base64_encode(b"var a;\0")})), "notText");
// Past 8 MiB as text and as base64: only the head is decoded.
let big = "a".repeat((8 << 20) + 1);
assert_eq!(kind(json!({"data": big})), "tooLarge");
assert_eq!(kind(json!({"dataBase64": vectorcraft_format::base64_encode(big.as_bytes())})), "tooLarge");
assert_eq!(kind(json!({"data": &big[1..]})), "script");
let mut s = Session::new();
for (p, want) in [
(json!({}), "give `path`, `data` or `dataBase64`"),
(json!({"dataBase64": "@@@"}), "bad dataBase64"),
(json!({"path": std::env::temp_dir().join("vc-no-such-script.jsx").to_string_lossy()}), "vc-no-such-script.jsx"),
] {
let e = s.execute("script.check", &p).unwrap_err().to_string();
assert!(e.contains(want), "{p}: {e}");
}
}
+3 -1
View File
@@ -159,6 +159,8 @@ fn caps_and_feature_overrides_pass_through_to_editable_type() {
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"CAP\x01"))), (tag(b"Valu"), F::I32(1))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"liga"))), (tag(b"Valu"), F::I32(0))],
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"ss01"))), (tag(b"Valu"), F::I32(1))],
// Character variants aren't rendered yet (stylistic sets are, #1607).
vec![(tag(b"Feat"), F::U32(u32::from_be_bytes(*b"cv01"))), (tag(b"Valu"), F::I32(1))],
],
),
)],
@@ -169,7 +171,7 @@ fn caps_and_feature_overrides_pass_through_to_editable_type() {
let t = texts(&loaded.doc)[0];
assert_eq!(t.runs[0].text, "Mixed");
assert!(t.runs[0].style.all_caps);
assert_eq!(t.runs[0].style.features, ["-liga", "ss01"]);
assert_eq!(t.runs[0].style.features, ["-liga", "ss01", "cv01"]);
assert!(loaded.warnings.iter().any(|w| w.contains("retained but not rendered")));
let restored = vectorcraft_format::load(&vectorcraft_format::save_file(&loaded.doc)).unwrap();
assert_eq!(texts(&restored)[0], t);
+114 -1
View File
@@ -8,7 +8,8 @@
//! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions), nor the
//! font files a folder search reads (damaged fonts, collections whose headers give any count of
//! faces).
//! faces), nor scripts written for the reference app checked without running them (garbage,
//! generated script text and mutated scripts).
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
@@ -2327,3 +2328,115 @@ proptest! {
search_reads("mutated font file", &bytes)?;
}
}
// ---------- scripts checked without running them ----------
/// A script written for these tests, with invented names: directives in both forms, member chains,
/// files, a dialog, menu commands, E4X, an operator and a message to another app.
const SCRIPT_SAMPLE: &str = concat!(
"#target acme_2-12-en_US\n",
"//@include \"lib/util.jsxinc\"\n",
"#includepath \"lib;shared\"\n",
"#targetengine session\n",
"var doc = app.activeDocument;\n",
"var w = new Window(\"dialog { text: 'Tidy' }\");\n",
"var f = File.saveDialog('Save'); f.open('w'); f.write(doc.acmeName); f.close();\n",
"app.executeMenuCommand('acmecopyall');\n",
"var x = <root><item id=\"1\">{doc.acmeName}</item></root>;\n",
"for each (var i in x..item) { $.writeln(i.@id); }\n",
"Vec.prototype['+'] = function (o) { return o; };\n",
"var bt = new BridgeTalk(); bt.target = 'acme'; bt.send();\n",
"if (item.typename == 'AcmeShape') { alert(/a[/]b/g.test(s) ? 'yes' : \"no\"); }\n",
);
/// The pieces generated scripts are made of.
const SCRIPT_PIECES: &[&str] = &[
"#target acme",
"//@include 'a.jsxinc'",
"#includepath 'x;..'",
"#pragma",
"app",
"activeDocument",
"selection",
"var doc = ",
".",
".acmeItems",
"(",
")",
"[",
"]",
"{",
"}",
";",
"=",
"'str'",
"\"open",
"/re[/]x/g",
"/",
"<a b=\"{v}\">",
"</a>",
"<!--",
".@",
"..",
"`t",
"=>",
"\n",
"\r",
"\u{2028}",
"@JSXBIN@",
"executeMenuCommand",
"new File(p)",
"Window(",
"import",
"/*",
"\\",
"0x1F",
" ",
];
/// `script.check` with `p`, which mustn't panic, and whose lists keep to their limit.
fn checked(what: &str, p: Value) -> Result<(), TestCaseError> {
let r = catch_quiet(|| no_panic(vectorcraft_engine::Session::new().execute("script.check", &p)))
.map_err(|e| TestCaseError::fail(format!("{what}: script.check panicked: {e}")))?;
if let Ok(r) = r {
for list in [&r["targets"], &r["directives"], &r["includes"], &r["problems"], &r["dom"]["members"], &r["menuCommands"]["strings"]] {
prop_assert!(list.as_array().is_some_and(|l| l.len() <= 1000), "{}: {}", what, list);
}
}
Ok(())
}
/// The sample the properties mutate reaches every part of the check.
#[test]
fn the_script_sample_is_checked_whole() {
let r = vectorcraft_engine::Session::new().execute("script.check", &json!({"data": SCRIPT_SAMPLE})).unwrap();
assert_eq!(r["kind"], "script");
assert_eq!(r["needs"], json!(["language", "dom", "files", "menuCommands", "dialogs", "hostOnly"]));
assert_eq!(r["problems"], json!([]));
}
proptest! {
#![proptest_config(config())]
#[test]
fn script_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..2_000), compiled in any::<bool>()) {
let name = if compiled { "garbage.jsxbin" } else { "garbage.jsx" };
checked("script garbage", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": name}))?;
}
#[test]
fn generated_scripts_never_panic(pieces in prop::collection::vec(prop::sample::select(SCRIPT_PIECES.to_vec()), 0..120)) {
checked("generated script", json!({"data": pieces.concat()}))?;
}
#[test]
fn mutated_scripts_never_panic(cut in 0usize..1_200, edits in prop::collection::vec((0usize..1_200, any::<u8>()), 0..12)) {
let mut bytes = SCRIPT_SAMPLE.as_bytes().to_vec();
for (at, b) in edits {
let n = bytes.len();
bytes[at % n] = b;
}
bytes.truncate(cut);
checked("mutated script", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "sample.jsx"}))?;
}
}
+25
View File
@@ -0,0 +1,25 @@
//! #1604: a document opened by a path relative to the working directory finds its relatively
//! linked images, as when opened by its absolute path. Its own test binary: it changes the
//! process's working directory.
// 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)]
use serde_json::json;
use vectorcraft_engine::Session;
#[test]
fn a_relative_path_finds_relative_links() {
let dir = std::env::temp_dir().join(format!("vectorcraft-relative-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
std::fs::write(dir.join("ext.png"), &png).unwrap();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><image x="0" y="0" width="40" height="40" href="ext.png"/></svg>"#;
std::fs::write(dir.join("linked.svg"), svg).unwrap();
std::env::set_current_dir(&dir).unwrap();
let mut s = Session::new();
let r = s.execute("document.open", &json!({"path": "linked.svg"})).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(r["warnings"], json!([]), "{r}");
assert_eq!(r["missingLinks"], json!([]), "{r}");
}
+22
View File
@@ -22,6 +22,28 @@ fn a_journal_with_a_key_no_longer_read_still_replays() {
assert_eq!((st.doc.artboards[0].rect.width(), st.doc.layers[0].children().unwrap().len()), (300.0, 1));
}
/// So do an effect's own parameters (#1592): a journal or a file with a key the effect doesn't
/// take, or a value of another type, replays and opens; the effect reads what it knows, clamped.
#[test]
fn effect_params_no_longer_taken_still_replay_and_open() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
let old = [
("effect.apply", json!({"effect": "blur.gaussian", "params": {"blurr": 3, "radius": "big"}})),
("effect.setParams", json!({"index": 0, "params": {"amount": 1e308}})),
];
for (id, params) in &old {
assert!(s.check_params(id, params).is_err(), "{id}");
s.execute(id, params).unwrap();
}
let doc = &s.active().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
let stored = |d: &vectorcraft_doc::Document| d.layers[0].children().unwrap()[0].appearance.effects[0].params.clone();
assert_eq!(stored(&back), stored(doc));
assert_eq!(stored(&back)["blurr"], json!(3));
}
/// What a command adds to its own journal entry passes the check, so a recorded journal can be
/// sent back through the control channel or MCP.
#[test]
+5 -2
View File
@@ -158,8 +158,11 @@ impl GradientDef {
if self.gradient.stops.is_empty() {
return None;
}
// Stops come last first: reversed, stops sharing a ramp point keep their order.
self.gradient.stops.reverse();
// Stops are listed along the ramp from either end: listed downwards they're reversed, so
// stops sharing a ramp point keep their order (and each its midpoint, to the next stop).
if self.gradient.stops.first().zip(self.gradient.stops.last()).is_some_and(|(first, last)| first.offset > last.offset) {
self.gradient.stops.reverse();
}
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
Some((self.name, self.gradient))
}
+13
View File
@@ -410,6 +410,19 @@ fn stops_sharing_a_ramp_point_keep_their_order() {
assert_eq!(colors, [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 0.0)]);
}
#[test]
fn stops_listed_upwards_keep_the_midpoint_of_the_one_that_starts_the_ramp() {
// GUARDS: reversing a list that runs upwards swaps two stops at 0, and the ramp to 100 takes the
// midpoint of the zero-length part between them.
let defs = "%AI5_BeginGradient: (U)\n(U) 0 3 Bd\n[\n<00FF>\n0\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n\
%_0 0 0 0 1 1 1 2 1 6 87 0 Bs\n%_0 0 0 0 0 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let art_ = format!("{}Bb\n1 (U) 0 0 0 1 1 0 0 1 0 0 1 Bg\n10 0 0 10 100 50 Xm\nf\n0 BB\n", rect_path(0.0, 0.0, 200.0, 100.0));
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let Paint::Gradient(g) = fill(&art(&doc)[0]) else { panic!() };
let midpoints: Vec<_> = g.gradient.stops.iter().map(|s| s.midpoint).collect();
assert_eq!(midpoints, [0.5, 0.87, 0.5]);
}
#[test]
fn per_anchor_data_reads() {
let art_ = format!("{RED}{}f\n(00800080) Xt 0 XD\n%_(00004000) Xt 1 XD\n", rect_path(0.0, 0.0, 10.0, 10.0));
+3 -3
View File
@@ -366,9 +366,9 @@ impl Out {
/// A new clip of `path` inside `clips`; `None` for a rectangle around the whole page, which
/// clips nothing.
fn clip(&mut self, path: BezPath, rule: FillRule) -> Option<Arc<Clip>> {
let lines = path.elements().iter().all(|e| !matches!(e, PathEl::QuadTo(..) | PathEl::CurveTo(..)));
let around = path.bounding_box().inflate(0.5, 0.5);
if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) {
// Only a polygon that really covers the page: a diamond touching its edges has the page as
// its bounding box but cuts its corners away (#1542).
if path.elements().len() <= 6 && vectorcraft_geom::hit::polygon_covers_rect(&path, self.frame, 0.5) {
return None;
}
self.next_clip += 1;
+22
View File
@@ -248,6 +248,12 @@ fn clips_become_clipping_groups() {
// eoclip of a path.
let o = objects(&read("10 10 moveto 30 10 lineto 30 30 lineto closepath eoclip newpath 0 0 50 50 rectfill").document);
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }));
// #1542: a diamond touching the page edges has the page as its bounding box but still clips.
let o = objects(&read("50 0 moveto 100 50 lineto 50 100 lineto 0 50 lineto closepath clip newpath 0 0 100 100 rectfill").document);
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", o[0].kind);
// A turned square around the whole page clips nothing.
let o = objects(&read("50 -60 moveto 160 50 lineto 50 160 lineto -60 50 lineto closepath clip newpath 0 0 100 100 rectfill").document);
assert!(matches!(o[0].kind, NodeKind::Path { .. }), "{:?}", o[0].kind);
}
#[test]
@@ -467,6 +473,22 @@ fn the_bounding_box_comes_from_the_header_or_the_trailer() {
assert!(r.warnings.iter().any(|w| w.contains("first of the file's 2 pages")), "{:?}", r.warnings);
}
/// #1552: a Type 42 font's glyph space is one unit an em, so its identity FontMatrix scaled to 12
/// sets 12 pt type (a Type 1's 0.001 matrix needs the 1000-unit space).
#[test]
fn type_42_fonts_set_type_at_their_size() {
for (kind, matrix) in [(42, "[1 0 0 1 0 0]"), (1, "[0.001 0 0 0.001 0 0]")] {
let body = format!(
"/T 10 dict begin /FontName /T def /FontType {kind} def /FontMatrix {matrix} def /Encoding StandardEncoding def currentdict end /T exch definefont pop \
/T findfont 12 scalefont setfont 10 20 moveto (Hi) show"
);
let r = read(&body);
let NodeKind::Text(t) = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
let size = t.runs[0].style.size * t.xf.determinant().abs().sqrt();
assert!((size - 12.0).abs() < 1e-6, "FontType {kind}: {size} pt");
}
}
#[test]
fn embedded_font_programs_are_skipped() {
let body = "/MyFont 10 dict begin /FontName /MyFont def /FontMatrix [0.001 0 0 0.001 0 0] def currentdict end currentfile eexec\n\
+4 -1
View File
@@ -403,7 +403,10 @@ impl Interp<'_> {
let f = font.borrow();
let name = f.get(&Key::name("FontName")).and_then(Obj::text).map_or_else(|| DEFAULT_FONT.to_string(), |n| n.to_string());
let m = f.get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| matrix_of(&i.borrow()))).unwrap_or(Affine::scale(0.001));
Ok((name, m * Affine::scale(1000.0)))
// Type 1 glyphs are in 1000-unit space; a Type 42 font's glyph space is one unit an em
// (its FontMatrix, usually the identity, is already the em's).
let units = if f.get(&Key::name("FontType")).and_then(Obj::as_num) == Some(42.0) { 1.0 } else { 1000.0 };
Ok((name, m * Affine::scale(units)))
}
/// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there,
+5
View File
@@ -15,6 +15,11 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
flate2 = { workspace = true }
# A plain fsync where a volume refuses the full flush (`sync_file`, atomic.rs). Already in
# Cargo.lock (tempfile), so this adds no new crate.
[target.'cfg(target_vendor = "apple")'.dependencies]
rustix = { workspace = true, features = ["std", "fs"] }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
+264 -9
View File
@@ -1,6 +1,13 @@
//! Safe saving: a file is written to a temporary file beside it, then renamed over it, so a failed
//! or interrupted write never leaves a damaged file behind (the old one stays as it was).
//! [`write_new_with`] creates a file the same way without ever replacing one.
//!
//! 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.
//!
//! 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).
use std::fs::{File, OpenOptions};
use std::io::{self, Write as _};
@@ -13,18 +20,88 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
}
/// Write `path` atomically with `fill`, which writes the content into the temporary file. When
/// anything fails (`fill`, flushing, the rename) the temporary file is removed and `path` is left
/// untouched.
/// `fill` or flushing fails the temporary file is removed and `path` is left untouched. When the
/// folder refuses the temporary file or the rename, the content is copied into `path` instead (see
/// the module docs).
pub fn write_atomic_with(path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
write_atomic_stepping(path, fill, create_new, |tmp, target| std::fs::rename(tmp, target))
}
fn create_new(path: &Path) -> io::Result<File> {
OpenOptions::new().write(true).create_new(true).open(path)
}
/// [`write_atomic_with`], with `create` making the temporary file beside the target and `rename`
/// moving it over the target.
fn write_atomic_stepping(
path: &Path,
fill: impl FnOnce(&mut File) -> io::Result<()>,
create: impl FnOnce(&Path) -> io::Result<File>,
rename: impl FnOnce(&Path, &Path) -> io::Result<()>,
) -> io::Result<()> {
let target = resolve_link(path)?;
let tmp = temp_path(&target)?;
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
let filled = fill(&mut f).and_then(|()| f.sync_all());
let beside = temp_path(&target)?;
// `refused`: why the folder takes no temporary file (it is in the system's folder then).
let (tmp, mut f, refused) = match create(&beside) {
Ok(f) => (beside, f, None),
// A folder that isn't there, a full volume: nothing can be saved there.
Err(e) if matches!(e.kind(), io::ErrorKind::NotFound | io::ErrorKind::StorageFull | io::ErrorKind::QuotaExceeded) => return Err(e),
Err(e) => {
let Some(tmp) = beside.file_name().map(|n| std::env::temp_dir().join(n)) else { return Err(e) };
let Ok(f) = create_private(&tmp) else { return Err(e) };
(tmp, f, Some(e))
}
};
let filled = fill(&mut f).and_then(|()| sync_file(&f));
drop(f);
filled.and_then(|()| keep_permissions(&target, &tmp)).and_then(|()| std::fs::rename(&tmp, &target)).inspect_err(|_| {
if let Err(e) = filled {
// Best effort: the temporary file is all there is to clean up.
let _ = std::fs::remove_file(&tmp);
})
return Err(e);
}
let refused = match refused {
Some(e) => e,
None => {
// Best effort: a volume without Unix permissions (SMB, FAT) refuses them, and has none
// to keep.
let _ = keep_permissions(&target, &tmp);
let Err(e) = rename(&tmp, &target) else { return Ok(()) };
e
}
};
write_over(&tmp, &target, refused)
}
/// 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.
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 {
// Best effort: the temporary file is all there is to clean up.
let _ = std::fs::remove_file(tmp);
return Err(refused);
};
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.
let _ = std::fs::remove_file(tmp);
Ok(())
}
Err(e) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))),
}
}
/// 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();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
options.open(path)
}
/// Create `path` with the content `fill` writes, never replacing a file or a link of that name. The
@@ -45,7 +122,7 @@ fn write_new_linking(
) -> io::Result<()> {
let tmp = temp_path(path)?;
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
let filled = fill(&mut f).and_then(|()| f.sync_all());
let filled = fill(&mut f).and_then(|()| sync_file(&f));
drop(f);
let placed = filled.and_then(|()| match link(&tmp, path) {
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => copy_new(&tmp, path),
@@ -60,7 +137,7 @@ fn write_new_linking(
/// removes it again.
fn copy_new(from: &Path, path: &Path) -> io::Result<()> {
let mut to = OpenOptions::new().write(true).create_new(true).open(path)?;
let copied = File::open(from).and_then(|mut f| io::copy(&mut f, &mut to)).and_then(|_| to.sync_all());
let copied = File::open(from).and_then(|mut f| io::copy(&mut f, &mut to)).and_then(|_| sync_file(&to));
drop(to);
if copied.is_err() {
// Best effort: the part copied is all there is to clean up.
@@ -69,6 +146,63 @@ fn copy_new(from: &Path, path: &Path) -> io::Result<()> {
copied
}
/// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush
/// (`F_FULLFSYNC`); where the volume refuses it with `ENOTSUP` (SMB shares, #1620) this falls back
/// to a plain `fsync`. Any other error, and a failed `fsync`, is returned as it is.
pub fn sync_file(file: &File) -> io::Result<()> {
match full_sync(file) {
Err(e) if full_flush_unsupported(&e) => plain_fsync(file),
r => r,
}
}
/// Whether `e` is a volume refusing a full flush. Never off Apple platforms: there `sync_all` is
/// already a plain `fsync`. Checked by its code because std maps `ENOTSUP` to `Uncategorized`.
fn full_flush_unsupported(e: &io::Error) -> bool {
cfg!(target_vendor = "apple") && e.raw_os_error() == Some(ENOTSUP)
}
#[cfg(target_vendor = "apple")]
fn plain_fsync(file: &File) -> io::Result<()> {
#[cfg(test)]
{
PLAIN_FSYNCS.with(|c| c.set(c.get() + 1));
if let Some(errno) = PLAIN_FSYNC_FAILS.with(std::cell::Cell::get) {
return Err(io::Error::from_raw_os_error(errno));
}
}
rustix::fs::fsync(file).map_err(io::Error::from)
}
#[cfg(not(target_vendor = "apple"))]
fn plain_fsync(file: &File) -> io::Result<()> {
file.sync_all()
}
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));
}
file.sync_all()
}
/// What macOS answers when a volume can't make a full flush (SMB shares).
#[cfg(target_vendor = "apple")]
const ENOTSUP: i32 = 45;
#[cfg(not(target_vendor = "apple"))]
const ENOTSUP: i32 = 95;
#[cfg(test)]
thread_local! {
/// Tests: every full flush on this thread fails as on an SMB share mounted by macOS.
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: how many plain `fsync` fallbacks were made on this thread.
static PLAIN_FSYNCS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// A symbolic link keeps pointing at its file: write the file it names.
fn resolve_link(path: &Path) -> io::Result<PathBuf> {
match std::fs::symlink_metadata(path) {
@@ -137,6 +271,127 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// A save to an SMB share (#1620): macOS refuses the full flush there with `ENOTSUP`, so the
/// file is flushed with a plain `fsync` and saved. When that fails too the save fails as before,
/// the old file stays and no temporary file is left. Elsewhere `sync_all` is already a plain
/// `fsync`: a refusal is an error like any other.
#[test]
fn a_volume_that_refuses_the_full_flush_still_saves() {
let d = dir("no-full-sync");
let path = d.join("doc.vectorcraft");
std::fs::write(&path, b"original").unwrap();
let apple = cfg!(target_vendor = "apple");
REFUSE_FULL_SYNC.with(|c| c.set(true));
let before = PLAIN_FSYNCS.with(std::cell::Cell::get);
let saved = write_atomic(&path, b"new");
let created = write_new_with(&d.join("new.otf"), |f| f.write_all(b"created"));
let fsyncs = PLAIN_FSYNCS.with(std::cell::Cell::get) - before;
PLAIN_FSYNC_FAILS.with(|c| c.set(Some(5)));
let failed = write_atomic(&path, b"lost");
PLAIN_FSYNC_FAILS.with(|c| c.set(None));
REFUSE_FULL_SYNC.with(|c| c.set(false));
assert_eq!(saved.is_ok(), apple, "{saved:?}");
assert_eq!(created.is_ok(), apple, "{created:?}");
assert_eq!(fsyncs, if apple { 2 } else { 0 }, "each refused full flush falls back to fsync");
assert!(failed.is_err(), "a failed fsync still fails the save");
assert_eq!(std::fs::read(&path).unwrap(), if apple { &b"new"[..] } else { b"original" });
let mut left = leftovers(&d, "doc.vectorcraft");
left.retain(|n| n != "new.otf");
assert!(left.is_empty(), "no temporary file is left: {left:?}");
let _ = std::fs::remove_dir_all(&d);
}
fn denied<T>() -> io::Result<T> {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "refused by the share"))
}
/// A folder that refuses the rename over the file (#1620: some network shares): the new content,
/// whole in the temporary file, is copied into the file itself. A new file is created.
#[test]
fn a_refused_rename_copies_the_content_into_the_file() {
let d = dir("no-rename");
let path = d.join("doc.vectorcraft");
std::fs::write(&path, b"original, and longer").unwrap();
write_atomic_stepping(&path, |f| f.write_all(b"new"), create_new, |_, _| denied()).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
write_atomic_stepping(&d.join("other.vectorcraft"), |f| f.write_all(b"other"), create_new, |_, _| denied()).unwrap();
assert_eq!(std::fs::read(d.join("other.vectorcraft")).unwrap(), b"other");
assert_eq!(leftovers(&d, "doc.vectorcraft"), ["other.vectorcraft"], "the temporary files are removed");
// A failed write never gets as far as the file.
let r = write_atomic_stepping(&path, |f| f.write_all(b"half").and(Err(io::Error::other("disk full"))), create_new, |_, _| denied());
assert_eq!(r.unwrap_err().to_string(), "disk full");
assert_eq!(std::fs::read(&path).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&d);
}
/// A refused rename where the file can't be written either (here: a folder of that name): the
/// rename's error, nothing changed and no temporary file left.
#[test]
fn a_refused_rename_over_a_file_that_cannot_be_written_changes_nothing() {
let d = dir("no-rename-no-write");
let path = d.join("doc.vectorcraft");
std::fs::create_dir(&path).unwrap();
let r = write_atomic_stepping(&path, |f| f.write_all(b"new"), create_new, |_, _| denied());
assert_eq!(r.unwrap_err().to_string(), "refused by the share");
assert!(path.is_dir() && leftovers(&d, "doc.vectorcraft").is_empty());
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.
#[test]
fn a_failed_copy_into_the_file_keeps_the_temporary_file() {
let d = dir("no-rename-copy-fails");
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,
|_, _| {
// From here on flushing fails (also the plain `fsync` macOS falls back to).
REFUSE_FULL_SYNC.with(|c| c.set(true));
PLAIN_FSYNC_FAILS.with(|c| c.set(Some(5)));
denied()
},
);
REFUSE_FULL_SYNC.with(|c| c.set(false));
PLAIN_FSYNC_FAILS.with(|c| c.set(None));
let message = r.unwrap_err().to_string();
let left = leftovers(&d, "doc.vectorcraft");
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");
let _ = std::fs::remove_dir_all(&d);
}
/// A folder that takes no temporary file (#1620: a share that refuses hidden or new names) but
/// lets the file be written: the content goes through the system's temporary folder, and no
/// rename is tried. A folder that isn't there is still an error.
#[test]
fn a_folder_that_refuses_the_temporary_file_still_saves() {
let d = dir("no-temp");
let path = d.join("doc.vectorcraft");
std::fs::write(&path, b"original, and longer").unwrap();
let renamed = std::cell::Cell::new(false);
let rename = |_: &Path, _: &Path| {
renamed.set(true);
Ok(())
};
write_atomic_stepping(&path, |f| f.write_all(b"new"), |_| denied(), rename).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert!(!renamed.get() && leftovers(&d, "doc.vectorcraft").is_empty());
// The file can't be written either (a folder of that name): the folder's error.
let folder = d.join("folder");
std::fs::create_dir(&folder).unwrap();
let r = write_atomic_stepping(&folder, |f| f.write_all(b"new"), |_| denied(), |_, _| Ok(()));
assert_eq!(r.unwrap_err().to_string(), "refused by the share");
let r = write_atomic_stepping(&path, |f| f.write_all(b"lost"), |_| Err(io::Error::from(io::ErrorKind::NotFound)), |_, _| Ok(()));
assert_eq!(r.map_err(|e| e.kind()), Err(io::ErrorKind::NotFound));
assert_eq!(std::fs::read(&path).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn write_new_never_replaces_what_is_there() {
let d = dir("new");
+1 -1
View File
@@ -45,7 +45,7 @@ use serde::{Deserialize, Serialize};
use vectorcraft_doc::{Document, ImageBlob};
#[cfg(not(target_arch = "wasm32"))]
pub use atomic::{write_atomic, write_atomic_with, write_new_with};
pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with};
use stream::{Gz, Indent, KeyScan, Out};
/// v4: large data after the JSON (v1 to v3 files still load).
+53 -2
View File
@@ -155,8 +155,8 @@ impl Corner {
let (h_out, h_in) = match kind {
// An arc tangent to both sides, turning by 180° less the angle between them there.
CornerKind::Round => {
let h = radius * arc_handle(-cos);
(start - du * h, end - dv * h)
let (hu, hv) = self.round_handles(radius, (start, du), (end, dv));
(start - du * hu, end - dv * hv)
}
// An arc centred on the corner through both ends, spanning the angle.
CornerKind::InvertedRound => {
@@ -169,6 +169,33 @@ impl Corner {
}
}
impl Corner {
/// The handle lengths of a round cut of `radius` between `start` and `end` (each a point and
/// the direction its side runs on in there). Between straight sides they are the arc's. A
/// curved side has turned by the time the cut meets it, so the arc's handles would reach past
/// where the tangents at the cut's ends cross and the cut would stick out of the corner
/// (#1512): there each handle is the share an arc takes of its tangent, up to that crossing
/// (a third of the way to the other end where the tangents don't cross between them).
fn round_handles(&self, radius: f64, (start, du): (Point, Vec2), (end, dv): (Point, Vec2)) -> (f64, f64) {
let cos = du.dot(dv);
let arc = arc_handle(-cos);
if self.side_u.straight() && self.side_v.straight() {
return (radius * arc, radius * arc);
}
// start − du·a = end − dv·b is where the tangents cross.
let (cross, w) = (du.cross(dv), start - end);
let (a, b) = (w.cross(dv) / cross, w.cross(du) / cross);
if cross.abs() > MIN_SINE && a > EPS && b > EPS && a.is_finite() && b.is_finite() {
// An arc's handle over its tangent's length, radius · tan(turn / 2).
let share = arc * (1.0 - cos) / cross.abs();
(a * share, b * share)
} else {
let third = start.distance(end) / 3.0;
(third, third)
}
}
}
/// A corner cut: the parameters where it meets its sides (from the corner), its two new anchors
/// and the handles between them.
#[derive(Clone, Copy, Debug)]
@@ -401,6 +428,30 @@ mod tests {
}
}
/// A round cut next to a curve stays inside its corner, however large (#1512): its handles
/// stop short of where the tangents at its ends cross, so it doesn't stick out past the sides.
#[test]
fn a_round_cut_next_to_a_curve_stays_inside_its_corner() {
// A square whose top side arches up, meeting the upright sides at an angle.
let mut sp =
SubPath::polyline(&[Point::new(100.0, 150.0), Point::new(300.0, 150.0), Point::new(300.0, 300.0), Point::new(100.0, 300.0)], true);
sp.anchors[0].h_out = Point::new(120.0, 20.0);
sp.anchors[1].h_in = Point::new(280.0, 20.0);
let path = PathData::single(sp);
let bounds = path.bounds().unwrap().inflate(1e-6, 1e-6);
for radius in [10.0, 60.0, 200.0, 1e6] {
let (cut, _) = cut_corners(&path, &[radius, radius], &[]);
let b = cut.bounds().unwrap();
assert!(bounds.union(b) == bounds, "radius {radius}: {b:?} is outside {bounds:?}");
// Each cut's handles end before its tangents cross: the cut turns one way only.
let a = &cut.subpaths[0].anchors;
for (from, to) in [(a[a.len() - 1], a[0]), (a[1], a[2])] {
let (t0, t1, chord) = (from.h_out - from.p, to.p - to.h_in, to.p - from.p);
assert!(t0.cross(chord) * chord.cross(t1) > 0.0 && t0.cross(t1) * t0.cross(chord) > 0.0, "radius {radius}: {from:?} {to:?}");
}
}
}
/// Inverted round and chamfer cuts end where the round one does, at any angle.
#[test]
fn kinds_share_their_ends() {
+67
View File
@@ -6,6 +6,47 @@ use kurbo::{BezPath, ParamCurveNearest, PathEl, Point, Rect, Shape};
use crate::path::{FillRule, PathData};
/// Does the filled area of `path` cover all of `r` shrunk by `tol` on every side? Answered for one
/// convex polygon (a single subpath of straight segments), which covers the rect when it contains
/// its four corners; any other path gives `false`, so a caller treats it as not covering.
pub fn polygon_covers_rect(path: &BezPath, r: Rect, tol: f64) -> bool {
let mut pts: Vec<Point> = Vec::new();
for (i, el) in path.elements().iter().enumerate() {
match *el {
PathEl::MoveTo(p) if i == 0 => pts.push(p),
PathEl::LineTo(p) if i > 0 => pts.push(p),
PathEl::ClosePath if i > 0 => {}
_ => return false,
}
}
if pts.len() > 1 && pts.first() == pts.last() {
pts.pop();
}
if pts.len() < 3 {
return false;
}
// Convex: every turn goes the same way (straight runs allowed).
let n = pts.len();
let mut sign = 0.0f64;
for i in 0..n {
let (a, b, c) = (pts[i], pts[(i + 1) % n], pts[(i + 2) % n]);
let cross = (b - a).cross(c - b);
if cross.abs() <= 1e-9 {
continue;
}
if sign == 0.0 {
sign = cross.signum();
} else if cross.signum() != sign {
return false;
}
}
let inner = r.inflate(-tol, -tol);
sign != 0.0
&& [Point::new(inner.x0, inner.y0), Point::new(inner.x1, inner.y0), Point::new(inner.x1, inner.y1), Point::new(inner.x0, inner.y1)]
.into_iter()
.all(|c| fill_contains(path, FillRule::NonZero, c))
}
/// Is `p` inside the filled area of `path` under `rule`? Open subpaths are implicitly closed (as when filled).
pub fn fill_contains(path: &BezPath, rule: FillRule, p: Point) -> bool {
let w = closed(path).winding(p);
@@ -94,6 +135,32 @@ mod tests {
use super::*;
use crate::shapes;
#[test]
fn polygon_covers_rect_only_for_convex_polygons_around_it() {
let page = Rect::new(0.0, 0.0, 100.0, 100.0);
let poly = |pts: &[(f64, f64)]| {
let mut b = BezPath::new();
b.move_to(pts[0]);
for &p in &pts[1..] {
b.line_to(p);
}
b.close_path();
b
};
assert!(polygon_covers_rect(&poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]), page, 0.01));
assert!(polygon_covers_rect(&poly(&[(50.0, -60.0), (160.0, 50.0), (50.0, 160.0), (-60.0, 50.0)]), page, 0.01), "turned, around it");
// The page's bounding box, but its corners are cut away.
assert!(!polygon_covers_rect(&poly(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]), page, 0.01));
// Contains the corners but notches into the page: not convex.
assert!(!polygon_covers_rect(&poly(&[(-1.0, -1.0), (101.0, -1.0), (101.0, 101.0), (50.0, 40.0), (-1.0, 101.0)]), page, 0.01));
// Curves and several subpaths aren't judged.
assert!(!polygon_covers_rect(&kurbo::Circle::new((50.0, 50.0), 200.0).to_path(0.1), page, 0.01));
let mut two = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
two.extend(poly(&[(10.0, 10.0), (20.0, 10.0), (20.0, 20.0)]));
assert!(!polygon_covers_rect(&two, page, 0.01));
assert!(!polygon_covers_rect(&BezPath::new(), page, 0.01));
}
#[test]
fn fill_and_stroke() {
let p = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)).to_bezpath();
+1 -1
View File
@@ -128,7 +128,7 @@ impl Headless {
// A key the command's description doesn't name is an error, not a default (#1442).
match HOST_COMMANDS.iter().find(|c| c.0 == id) {
Some((_, _, doc)) => vectorcraft_engine::check_keys(id, doc, params),
None => vectorcraft_engine::check_params(id, params),
None => self.session.check_params(id, params),
}
.map_err(|e| e.to_string())?;
match id {
+113 -19
View File
@@ -26,6 +26,8 @@ use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_sh
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
/// Mixed into an image's cache key when it is read white inside an alpha soft mask.
const WHITE_KEY: u128 = 0x7717_e000_a1fa;
/// The name of the paths text imports as.
const TEXT_OUTLINES: &str = "<Text Outlines>";
/// A page whose overprint (ISO 32000-1 §8.6.7) is left out rather than given to the wrong objects.
@@ -197,6 +199,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
b.colors.uncalibrated_rgb = opts.uncalibrated_rgb && picked.iter().filter_map(|&n| pages.get(n)).any(crate::pages::has_private_data);
let cache = InterpreterCache::new();
let mut x = 0.0;
// Where the next page's art may begin (a `.ai` page's art reaches past its artboard).
let mut clear = f64::NEG_INFINITY;
// Every page only text over private data: a file saved without its PDF part shows a notice
// there, not its art. A page that states where its art is isn't that notice.
let (mut only_text, mut art_boxes) = (true, false);
@@ -220,6 +224,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let xf = Affine::translate((x - frame.x0, -frame.y0)) * init;
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
b.page = ab;
// The first id of this page's art.
let first_id = b.next;
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts);
if route {
let mut seen = HashSet::new();
@@ -267,6 +273,33 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
if clipped {
b.warn(PAST_PAGE_NOTE);
}
// A `.ai` page keeps its art past the artboard edge, unclipped (#646), so a page whose art
// reaches far to the left would lay it over the page before it in the row: there it would
// be taken for that page's art (by the check against the editing data, which then finds
// art its layers don't have there, and by `place_pages`). Such a page moves right until its
// art clears the art before it.
let mut art_right = f64::NEG_INFINITY;
if ai && let Some(art) = parts.iter().flat_map(|(_, v)| v.iter()).filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)) {
// Art at a coordinate that isn't a number (a damaged file) moves nothing.
let dx = Some((clear - art.x0).max(0.0)).filter(|dx| dx.is_finite()).unwrap_or(0.0);
if dx > 0.0 {
let shift = Affine::translate((dx, 0.0));
for (_, v) in &mut parts {
for n in v.iter_mut() {
Arc::make_mut(n).transform(shift, false);
}
}
// What the page's lines of type were is told where their glyphs are (the outlines
// stroked on them are looked for there), so that moves with them.
for facts in b.lines.iter_mut().filter(|(id, _)| id.0 >= first_id).map(|(_, facts)| facts) {
for ink in &mut facts.ink {
*ink = *ink + kurbo::Vec2::new(dx, 0.0);
}
}
ab = Rect::new(ab.x0 + dx, ab.y0, ab.x1 + dx, ab.y1);
}
art_right = art.x1 + dx;
}
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
only_text &= ai && is_only_text(&children);
art_boxes |= crate::pages::states_art(page);
@@ -278,6 +311,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
ab = art;
}
x = right + opts.artboard_gap;
clear = right.max(art_right) + opts.artboard_gap;
doc.artboards.push(Artboard {
id: i as u32 + 1,
name: format!("Artboard {}", i + 1),
@@ -756,6 +790,10 @@ struct Builder<'p> {
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
pending: bool,
nested: u32,
/// Reading an alpha soft mask's art: images are read white with their own alpha, so the
/// mask's luminance is their alpha, as whitened vector art's is (cairo, and so Inkscape,
/// rasterizes masks into such images).
white_images: bool,
/// The last filled path: its node and its outline (a stroke of the same outline joins it).
last_fill: Option<(NodeId, BezPath)>,
/// What each line of type made tells beyond its text object ([`crate::import_lines`]).
@@ -898,6 +936,7 @@ impl<'p> Builder<'p> {
marked: vec![],
pending: false,
nested: 0,
white_images: false,
last_fill: None,
lines: HashMap::new(),
}
@@ -1202,11 +1241,50 @@ impl<'p> Builder<'p> {
}
fn flush_text(&mut self) {
let Some((t, opacity, facts)) = self.text.take().and_then(TextLine::finish) else { return };
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
n.opacity = opacity;
self.lines.insert(n.id, facts);
self.push_node(n);
let Some(mut line) = self.text.take() else { return };
let held = std::mem::take(&mut line.held);
if let Some((t, opacity, facts)) = line.finish() {
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
n.opacity = opacity;
self.lines.insert(n.id, facts);
self.push_node(n);
}
// The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke).
for n in held {
self.push_node(n);
}
}
/// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph
/// 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 };
if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, hayro_interpret::Paint::Pattern(_)) {
return false;
}
let (p, opacity) = self.paint(paint, true);
let Some(line) = &self.text else { return false };
let Some(ink) = line.ink.last() else { return false };
if line.held.len() + 1 != line.ink.len() || opacity < 0.999 || !line.last_fill().is_some_and(|f| *f == p) {
return false;
}
let mut bp = path.clone();
bp.apply_affine(transform);
let b = bp.bounding_box();
let tol = 0.01 * (ink.width() + ink.height()) + 0.01;
if ![(b.x0, ink.x0), (b.y0, ink.y0), (b.x1, ink.x1), (b.y1, ink.y1)].iter().all(|(a, c)| (a - c).abs() <= tol) {
return false;
}
let mut st = stroke_layer(p, opacity, props, mean_scale(transform));
// Every painted path takes its turn in the scan's overprint list, a held one too.
st.overprint = self.path_overprint();
let n = Node::path(self.id(), PathData::from_bezpath(&bp), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() });
if let Some(line) = &mut self.text {
line.held.push(n);
}
true
}
/// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke).
@@ -1267,7 +1345,10 @@ impl<'p> Builder<'p> {
return s.clone();
}
let page = self.page;
let spec = match self.nested(page, |b| m.interpret(b)) {
let saved = std::mem::replace(&mut self.white_images, m.mask_type() == MaskType::Alpha);
let art = self.nested(page, |b| m.interpret(b));
self.white_images = saved;
let spec = match art {
Some(art) => {
let [r, g, b, _] = m.background_color().to_rgba().components();
let backdrop = luminance(&Color::rgb(r, g, b));
@@ -1298,10 +1379,15 @@ impl<'p> Builder<'p> {
{
return Some(Paint::Pattern { pattern: name.clone(), xf });
}
// A swatch is shared with uses outside an alpha mask, and pattern paints keep their
// colours there: its images are read as they are.
let white = std::mem::take(&mut self.white_images);
let art = self.nested(Rect::ZERO, |b| {
// A cell whose content can't be decoded is empty.
let _ = t.interpret(b, Affine::IDENTITY, stroke);
})?;
});
self.white_images = white;
let art = art?;
let sig = paints(&art);
let entry = self.pattern_keys.entry(key).or_default();
entry.0 += 1;
@@ -1620,6 +1706,10 @@ impl<'a> Device<'a> for Builder<'_> {
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) {
self.flush_glyphs();
if self.hold_glyph_stroke(path, transform, paint, draw_mode) {
self.pending = false;
return;
}
self.flush_text();
self.pending = false;
let overprint = self.path_overprint();
@@ -1722,14 +1812,12 @@ impl<'a> Device<'a> for Builder<'_> {
(*isolate, *knockout) = (i, k);
}
}
let bb = clip_path.path.bounding_box();
// Clips that contain the whole page do nothing visible; don't create groups for them.
// Clips that contain the whole page do nothing visible; don't create groups for them. Only
// a polygon that really covers it counts: a diamond touching the page edges has the page
// as its bounding box but cuts its corners away (#1542).
let redundant = clip_path.path.elements().len() <= 6
&& bb.x0 <= self.page.x0 + 0.01
&& bb.y0 <= self.page.y0 + 0.01
&& bb.x1 >= self.page.x1 - 0.01
&& bb.y1 >= self.page.y1 - 0.01
&& self.page.area() > 0.0;
&& self.page.area() > 0.0
&& vectorcraft_geom::hit::polygon_covers_rect(&clip_path.path, self.page, 0.01);
let kind = if redundant { FrameKind::Skip } else { FrameKind::Clip(Box::new(self.clip_node(&clip_path.path, fill_rule(clip_path.fill)))) };
self.stack.push(Frame::new(kind));
}
@@ -1802,12 +1890,14 @@ impl<'a> Device<'a> for Builder<'_> {
self.pending = false;
match image {
Image::Raster(r) => {
let key = hayro_interpret::CacheKey::cache_key(&r);
let white = self.white_images;
// Inside an alpha mask the same image is read again, white (its own cache key).
let key = hayro_interpret::CacheKey::cache_key(&r) ^ if white { WHITE_KEY } else { 0 };
let st = r.stream();
let (w, h) = (r.width(), r.height());
// CMYK images keep their samples (read once per image).
let known = self.cmyk_keys.contains(&key);
let cmyk = if known {
let known = !white && self.cmyk_keys.contains(&key);
let cmyk = if known || white {
None
} else {
crate::import_image::cmyk(st, w, h).unwrap_or_else(|why| {
@@ -1826,7 +1916,8 @@ impl<'a> Device<'a> for Builder<'_> {
let dict = st.dict();
let filters = st.filters();
let cs_ok = dict.get::<Name<'_>>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray"));
let jpeg = filters.len() == 1
let jpeg = !white
&& filters.len() == 1
&& matches!(filters[0], hayro_syntax::Filter::DctDecode)
&& cs_ok
&& !dict.contains_key(b"SMask")
@@ -1843,6 +1934,7 @@ impl<'a> Device<'a> for Builder<'_> {
let (w, h, sf) = (img.width(), img.height(), img.scale_factors());
let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]);
let rgba: Vec<u8> = match img {
_ if white => a.into_iter().flat_map(|a| [255, 255, 255, a]).collect(),
ImageData::Rgb(d) => d.data.as_chunks::<3>().0.iter().zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(),
ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(),
};
@@ -1858,11 +1950,13 @@ impl<'a> Device<'a> for Builder<'_> {
self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf);
}
Image::Stencil(s) => {
let key = hayro_interpret::CacheKey::cache_key(&s);
let white = self.white_images;
let key = hayro_interpret::CacheKey::cache_key(&s) ^ if white { WHITE_KEY } else { 0 };
let mut decoded: Option<Decoded> = None;
s.with_stencil(
|luma, paint| {
let rgb = match paint {
_ if white => [255, 255, 255, 255],
hayro_interpret::Paint::Color(c) => c.to_rgba().to_rgba8(),
_ => [0, 0, 0, 255],
};
+24 -1
View File
@@ -11,7 +11,7 @@ use std::collections::HashMap;
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextObject, TextRun};
use vectorcraft_doc::{CharStyle, Node, ParaDirection, TextKind, TextObject, TextRun};
use vectorcraft_text::TextLayout;
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
@@ -144,6 +144,9 @@ pub(crate) struct TextLine {
gaps: Vec<(usize, f64)>,
/// Each glyph's advance in the file's font (points), with [`Self::baseline`].
advances: Vec<f64>,
/// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far
/// (written right after the line).
pub held: Vec<Node>,
}
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
@@ -177,9 +180,15 @@ impl TextLine {
ink: vec![],
gaps: vec![],
advances: vec![],
held: vec![],
}
}
/// The paint the last glyph is filled with.
pub fn last_fill(&self) -> Option<&Paint> {
self.runs.last()?.0.fill.as_ref()
}
/// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this
/// line's single glyph a column): below the last one, in the same column. `false`: it starts
/// another line.
@@ -350,6 +359,20 @@ impl TextLine {
t.runs = tracked_each(std::mem::take(&mut t.runs), own, self.at.size);
}
if let Some(path) = path {
// Evenly spaced type keeps its length along the path: the path runs through the
// glyphs' origins, one curve each, to the end of the last glyph, and tracking makes up
// the difference from the advances.
if own.is_none() {
let length: f64 = path.segments().take(self.baseline.len()).map(|seg| kurbo::ParamCurveArclen::arclen(&seg, 1e-3)).sum();
let laid = vectorcraft_text::layout(db, &t);
let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum();
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
let size = self.at.size;
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
let (tracking, spaces) = spacing(&laid, &[], length - natural, chars, size);
t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces);
}
}
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
+65
View File
@@ -78,6 +78,58 @@ fn the_mask(d: &Document) -> (Node, OpacityMask) {
(n, m)
}
/// The two pixels of the image a soft mask of kind `s` draws: black, opaque on the left and clear
/// on the right (its own `/SMask`).
fn mask_image_pixels(s: &str) -> [[u8; 4]; 2] {
let g = first_extra(1);
let group = stream(
&format!(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /XObject << /Im0 {} 0 R >> >>",
g + 1
),
"q 100 0 0 100 0 0 cm /Im0 Do Q",
);
let image = stream(
&format!(
"/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /SMask {} 0 R",
g + 2
),
"000000000000>",
);
let smask =
stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FF00>");
let bytes = one_page(
"/GS0 gs 1 0 0 rg 0 0 100 100 re f",
&format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /{s} /G {g} 0 R >> >> >>"),
&[&group, &image, &smask],
);
let d = open(&bytes);
let (_, m) = the_mask(&d);
let mut keys = vec![];
m.art.walk(&mut |n| {
if let NodeKind::Image(im) = &n.kind {
keys.push(im.key.clone());
}
});
let [key] = keys.as_slice() else { panic!("one image in the mask art: {keys:?}") };
let px = image::load_from_memory(&d.images[key].bytes).unwrap().to_rgba8();
[px.get_pixel(0, 0).0, px.get_pixel(1, 0).0]
}
/// An alpha soft mask whose art is an image with its own alpha (some PDF writers rasterize masks
/// so): the image is read white with that alpha, so the mask's luminance is the alpha, not the
/// image's colour (ISO 32000-1 11.5.2: an alpha mask takes the group's alpha, not its colour).
#[test]
fn an_alpha_soft_mask_of_an_image_uses_the_images_alpha() {
assert_eq!(mask_image_pixels("Alpha"), [[255, 255, 255, 255], [255, 255, 255, 0]]);
}
/// A luminosity soft mask takes the group's colour (ISO 32000-1 11.5.3): its image stays black.
#[test]
fn a_luminosity_soft_mask_of_an_image_keeps_the_images_colour() {
assert_eq!(mask_image_pixels("Luminosity"), [[0, 0, 0, 255], [0, 0, 0, 0]]);
}
#[test]
fn a_luminosity_soft_mask_becomes_an_opacity_mask() {
let d = open(&masked("/S /Luminosity", "1 1 1 rg 0 0 50 100 re f"));
@@ -544,3 +596,16 @@ fn glyphs_closed_up_by_half_an_em_stay_one_line() {
assert_eq!(t.len(), 1, "one line: {:?}", t.iter().map(|t| t.plain_text()).collect::<Vec<_>>());
assert_eq!(t[0].plain_text(), "Tight");
}
/// #1542: a clip only counts as covering the page when it does; a diamond whose bounding box is
/// the page still clips, a page rectangle or a turned square around it doesn't make a group.
#[test]
fn clips_with_the_page_as_bounding_box_still_clip() {
let clipped = |clip: &str| {
let d = open(&one_page(&format!("q {clip} W n 1 0.5 0 rg 0 0 100 100 re f Q"), "", &[]));
all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. }))
};
assert!(clipped("50 0 m 100 50 l 50 100 l 0 50 l h"), "diamond");
assert!(!clipped("0 0 100 100 re"), "the page");
assert!(!clipped("50 -60 m 160 50 l 50 160 l -60 50 l h"), "a turned square around the page");
}
+41
View File
@@ -131,6 +131,47 @@ fn a_stroke_written_as_stroked_glyph_outlines_is_the_types_stroke() {
assert_eq!(d.layers[0].children().unwrap().len(), 2);
}
/// A `.ai` page moved right in the row, so its art past the artboard clears the page before it
/// (#1528), still has the stroke drawn on its type's glyphs read as the type's stroke.
#[test]
fn a_stroke_on_the_type_of_a_moved_ai_page_is_still_the_types_stroke() {
let ai = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>";
let content = format!(
"0 g -70 10 80 20 re f BT 1 0 0 rg /F1 24 Tf 10 50 Td (Hi) Tj ET 0 0 1 RG 0.5 w {}{}",
outline('H', 10.0),
outline('i', 10.0 + 0.722 * 24.0)
);
let pages = [("0 g 10 10 20 20 re f", String::new()), (content.as_str(), HELVETICA.to_string())].map(|(c, resources)| PdfPage {
entries: ai.into(),
resources,
..PdfPage::new(100.0, 100.0, c)
});
let d = import(&vectorcraft_testkit::pdf::pdf(&pages, None)).unwrap();
assert!(d.artboards[1].rect.x0 > d.artboards[0].rect.x1 + 36.0 + 1.0, "page 2 moved: {:?}", d.artboards);
let art = d.layers[1].children().unwrap();
assert_eq!(art.len(), 2, "the note and the type: {art:?}");
assert!(matches!(art[1].appearance.items.as_slice(), [AppearanceItem::Stroke(_)]), "{:?}", art[1].appearance);
}
#[test]
fn glyphs_each_followed_by_their_stroked_outline_stay_one_object() {
// Type on a path is written glyph by glyph: each glyph, then its outline stroked in the same
// colour (a shadow under the type).
let glyph = |c: char, x: f64, stroke: &str| format!("BT 0 0 1 rg /F1 24 Tf {x} 50 Td ({c}) Tj ET {stroke} 0.5 w {}", outline(c, x));
let i_at = 10.0 + 0.722 * 24.0;
let d = import(&page(&(glyph('H', 10.0, "0 0 1 RG") + &glyph('i', i_at, "0 0 1 RG")))).unwrap();
let art = d.layers[0].children().unwrap();
assert_eq!(art.len(), 1, "one object: {art:?}");
let NodeKind::Text(t) = &art[0].kind else { panic!("{:?}", art[0].kind) };
assert_eq!(t.plain_text(), "Hi");
let [AppearanceItem::Stroke(st)] = art[0].appearance.items.as_slice() else { panic!("{:?}", art[0].appearance) };
assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
// A stroke of another colour drawn over each glyph in turn would also cover the next one's
// neighbour as one object's stroke: the glyphs stay as the file draws them.
let d = import(&page(&(glyph('H', 10.0, "1 1 1 RG") + &glyph('i', i_at, "1 1 1 RG")))).unwrap();
assert_eq!(texts(&d).len(), 2);
}
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3.
fn source_sans(lines: &[(f32, f32, f32, &str)]) -> Vec<u8> {
crate::tests_live_text::text_pdf(lines, false)
+21
View File
@@ -158,6 +158,27 @@ fn art_of_neighbouring_artboards_off_a_page_is_left_to_its_own_page() {
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 2);
}
/// A `.ai` keeps its art past the artboard edge (#646): a note mostly left of artboard 2 (as notes
/// for a design are put beside it) doesn't lie over artboard 1 in the row, where it would be taken
/// for that page's art: page 2 moves right until its art clears page 1's.
#[test]
fn art_of_an_ai_page_reaching_left_does_not_lie_over_the_page_before() {
let ai = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>";
let page = |content: &str| PdfPage { entries: ai.into(), ..PdfPage::new(100.0, 100.0, content) };
// Page 2's note runs from 70 points left of its box to 10 points into it.
let pages = [page("0 g 10 10 20 20 re f"), page("1 1 1 rg 1 0 0 RG -70 10 80 60 re B")];
let d = open(&pdf(&pages, None), ImportOptions::default());
let (a, b) = (d.artboards[0].rect, d.artboards[1].rect);
let note = d.layers[1].children().unwrap()[0].visual_bounds().unwrap();
assert!(note.x0 >= a.x1 + 36.0, "the note clears artboard 1: {note:?} {a:?}");
assert!((b.x0 - note.x0 - 70.0).abs() < 1.0, "and keeps its place by artboard 2: {note:?} {b:?}");
assert_eq!((b.width(), b.height()), (100.0, 100.0));
// A plain PDF clips its art to the page: its pages stay 36 points apart.
let plain = [PdfPage::new(100.0, 100.0, "0 g 10 10 20 20 re f"), PdfPage::new(100.0, 100.0, "1 0 0 RG -70 10 80 60 re S")];
let d = open(&pdf(&plain, None), ImportOptions::default());
assert_eq!(d.artboards[1].rect.x0, d.artboards[0].rect.x1 + 36.0);
}
#[test]
fn an_encrypted_pdf_opens_with_its_password_only() {
let bytes = pdf(&[PdfPage::new(200.0, 100.0, "1 0 0 rg 10 10 50 50 re f")], Some("secret"));
+19
View File
@@ -424,3 +424,22 @@ fn glyphs_spaced_each_their_own_way_keep_their_place() {
}
});
}
/// Evenly tracked type on a path comes back with its tracking: each glyph where it was along the
/// curve, not closed up to the font's advances.
#[test]
fn evenly_tracked_type_on_a_path_keeps_its_length() {
let mut arc = BezPath::new();
arc.move_to((40.0, 200.0));
arc.curve_to((100.0, 80.0), (260.0, 80.0), (320.0, 200.0));
let mut t = TextObject::point(Point::ORIGIN, "ARCHING TYPE", CharStyle { tracking: 80.0, ..style(24.0) });
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None };
let d = doc(vec![t]);
let want = origins(&d);
let back = import_as(&pdf(&d, false).bytes, TextAs::default());
let got = origins(&back);
assert_eq!(got.len(), want.len(), "{:?}", texts(&back));
for (g, w) in got.iter().zip(&want) {
assert!(g.distance(*w) < 0.3, "{got:?}\nvs {want:?}");
}
}
+217 -5
View File
@@ -341,9 +341,11 @@ fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) ->
let mut palette: Vec<[f32; 3]> = boxes.iter().map(|&(a, b, ..)| mean(&cells[a..b])).collect();
// k-means: move each entry to the mean of the cells nearest to it.
for _ in 0..3 {
// Centres move between passes, not within one: build the spatial index once per pass.
let index = PaletteIndex::new(&palette, w);
let mut sums = vec![([0f64; 3], 0f64); palette.len()];
for c in &cells {
let i = nearest(&palette, c.mean, w);
let i = index.nearest(c.mean);
for ch in 0..3 {
sums[i].0[ch] += c.mean[ch] as f64 * c.count as f64;
}
@@ -371,8 +373,137 @@ fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) ->
out
}
fn nearest(palette: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
palette.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
/// An exact 3-D nearest-neighbour index for the weighted RGB distance used by palette export.
///
/// Balanced at the weighted widest axis. Every subtree carries tight channel bounds, so a
/// branch-and-bound search skips it when even its nearest *possible* colour is farther than the
/// best one found. Equal distances still choose the earliest palette index (as a linear scan did).
/// For very small palettes a scan is cheaper than traversing the tree.
struct PaletteNode {
index: usize,
lo: [f32; 3],
hi: [f32; 3],
left: Option<usize>,
right: Option<usize>,
}
struct PaletteIndex<'a> {
colors: &'a [[f32; 3]],
weights: [f32; 3],
nodes: Vec<PaletteNode>,
root: Option<usize>,
}
impl<'a> PaletteIndex<'a> {
const SCAN_CUTOFF: usize = 12;
fn new(colors: &'a [[f32; 3]], weights: [f32; 3]) -> Self {
let mut nodes = if colors.len() <= Self::SCAN_CUTOFF { Vec::new() } else { Vec::with_capacity(colors.len()) };
let root = if colors.len() <= Self::SCAN_CUTOFF {
None
} else {
let mut indices: Vec<usize> = (0..colors.len()).collect();
Self::build(colors, weights, &mut indices, &mut nodes)
};
Self { colors, weights, nodes, root }
}
// Median partitioning takes O(k log k) time and space for k palette entries.
fn build(colors: &[[f32; 3]], w: [f32; 3], ids: &mut [usize], nodes: &mut Vec<PaletteNode>) -> Option<usize> {
if ids.is_empty() {
return None;
}
let (mut lo, mut hi) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
for &id in ids.iter() {
let Some(c) = colors.get(id) else { continue };
for axis in 0..3 {
lo[axis] = lo[axis].min(c[axis]);
hi[axis] = hi[axis].max(c[axis]);
}
}
let axis = (0..3).max_by(|&a, &b| (w[a] * (hi[a] - lo[a])).total_cmp(&(w[b] * (hi[b] - lo[b])))).unwrap_or(0);
let mid = ids.len() / 2;
ids.select_nth_unstable_by(mid, |&a, &b| {
let ac = colors.get(a).map_or(0.0, |c| c[axis]);
let bc = colors.get(b).map_or(0.0, |c| c[axis]);
ac.total_cmp(&bc).then(a.cmp(&b))
});
let (before, rest) = ids.split_at_mut(mid);
let (pivot, after) = rest.split_first_mut()?;
let at = nodes.len();
nodes.push(PaletteNode { index: *pivot, lo, hi, left: None, right: None });
let left = Self::build(colors, w, before, nodes);
let right = Self::build(colors, w, after, nodes);
if let Some(n) = nodes.get_mut(at) {
n.left = left;
n.right = right;
}
Some(at)
}
// Lower bound on *the same f32 weighted squared distance* as `dist`: avoid transforming
// coordinates, which would change rounding and potentially break identical-palette ties.
fn lower_bound(&self, id: usize, c: [f32; 3]) -> f32 {
let Some(n) = self.nodes.get(id) else { return f32::INFINITY };
(0..3)
.map(|i| {
let gap = if c[i] < n.lo[i] {
n.lo[i] - c[i]
} else if c[i] > n.hi[i] {
c[i] - n.hi[i]
} else {
0.0
};
(self.weights[i] * gap).powi(2)
})
.sum()
}
fn search(&self, at: usize, c: [f32; 3], best: &mut (f32, usize)) {
let Some(n) = self.nodes.get(at) else { return };
let Some(&p) = self.colors.get(n.index) else { return };
let d = dist(p, c, self.weights);
if d < best.0 || (d == best.0 && n.index < best.1) {
*best = (d, n.index);
}
// Search the more promising box first, tightening the bound for the second box.
let left = n.left.map(|id| (id, self.lower_bound(id, c)));
let right = n.right.map(|id| (id, self.lower_bound(id, c)));
match (left, right) {
(Some(a), Some(b)) => {
let (first, second) = if a.1 <= b.1 { (a, b) } else { (b, a) };
if first.1 <= best.0 {
self.search(first.0, c, best);
}
// `<=` is essential: an equal-distance colour may have an earlier index.
if second.1 <= best.0 {
self.search(second.0, c, best);
}
}
(Some(a), None) | (None, Some(a)) => {
if a.1 <= best.0 {
self.search(a.0, c, best);
}
}
(None, None) => {}
}
}
fn nearest(&self, c: [f32; 3]) -> usize {
if self.colors.len() <= Self::SCAN_CUTOFF {
return self
.colors
.iter()
.enumerate()
.min_by(|a, b| dist(*a.1, c, self.weights).total_cmp(&dist(*b.1, c, self.weights)))
.map_or(0, |(i, _)| i);
}
let mut best = (f32::INFINITY, 0);
if let Some(root) = self.root {
self.search(root, c, &mut best);
}
best.1
}
}
/// The 8×8 ordered-dither threshold at (x, y), in 0..1.
@@ -398,19 +529,20 @@ fn noise(x: usize, y: usize) -> f32 {
/// names (transparent) get index 0 and take no diffused error.
fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dither: Dither, amount: f32, skip: impl Fn(usize) -> bool) -> Vec<u8> {
let pal: Vec<[f32; 3]> = palette.iter().map(|c| c.map(f32::from)).collect();
let index = PaletteIndex::new(&pal, w);
// Nearest entries by colour: exact colours without dithering, a 6-bit grid with it.
let mut exact: HashMap<[u8; 3], u8> = HashMap::new();
let mut grid = vec![u16::MAX; if dither == Dither::None { 0 } else { 1 << 18 }];
let mut lookup = |c: [f32; 3]| -> u8 {
let q = c.map(|v| v.round().clamp(0.0, 255.0) as u8);
if grid.is_empty() {
return *exact.entry(q).or_insert_with(|| nearest(&pal, c, w) as u8);
return *exact.entry(q).or_insert_with(|| index.nearest(c) as u8);
}
let key = (q[0] as usize >> 2) << 12 | (q[1] as usize >> 2) << 6 | q[2] as usize >> 2;
match grid.get(key) {
Some(&v) if v != u16::MAX => v as u8,
_ => {
let i = nearest(&pal, c, w) as u8;
let i = index.nearest(c) as u8;
if let Some(g) = grid.get_mut(key) {
*g = i as u16;
}
@@ -466,3 +598,83 @@ fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dithe
}
out
}
#[cfg(test)]
mod nearest_tests {
use super::*;
// The original definition, retained only as a parity oracle for the spatial index.
fn linear(colors: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
colors.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
}
fn next(seed: &mut u32) -> u32 {
*seed ^= *seed << 13;
*seed ^= *seed >> 17;
*seed ^= *seed << 5;
*seed
}
#[test]
fn exact_nearest_matches_linear_for_palette_sizes_and_weights() {
let mut seed = 0x2ab4_1017;
for w in [[1.0; 3], [0.55, 0.77, 0.34], [0.1, 1.0, 0.01]] {
for size in [0, 1, 2, 8, 12, 13, 16, 17, 32, 64, 128, 256] {
// Include duplicated colours, close k-means-style floating-point centres,
// and a wide colour gamut. Query points are fractional, as with dithering.
let colors: Vec<[f32; 3]> =
(0..size)
.map(|i| {
if i % 11 == 0 {
[127.0, 127.0, 127.0]
} else {
[0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0))
}
})
.collect();
let index = PaletteIndex::new(&colors, w);
for _ in 0..500 {
let c = [0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0));
assert_eq!(index.nearest(c), linear(&colors, c, w), "size {size}, weights {w:?}, query {c:?}");
}
}
}
}
#[test]
fn equal_distance_always_chooses_first_palette_entry() {
// Enough entries to exercise the tree, even when they share one bounding box.
let mut colors = vec![[128.0, 128.0, 128.0]; 256];
colors[0] = [0.0, 128.0, 128.0];
colors[255] = [2.0, 128.0, 128.0];
let index = PaletteIndex::new(&colors, [1.0; 3]);
assert_eq!(index.nearest([1.0, 128.0, 128.0]), 0, "equal distance to indices 0 and 255");
assert_eq!(index.nearest([128.0; 3]), 1, "equal among 254 identical colours");
let colors = vec![[10.0, 10.0, 10.0]; 256];
assert_eq!(PaletteIndex::new(&colors, [0.55, 0.77, 0.34]).nearest([10.0; 3]), 0);
}
/// Opt-in microbenchmark: compares the same nearest-neighbour queries, without image IO.
#[test]
#[ignore = "run with cargo test -p vectorcraft-render nearest_palette_benchmark --release -- --ignored --nocapture"]
fn nearest_palette_benchmark() {
use std::time::Instant;
let mut seed = 0x87bb_7641;
let colors: Vec<[f32; 3]> = (0..256).map(|_| [0, 1, 2].map(|_| (next(&mut seed) % 256) as f32)).collect();
let queries: Vec<[f32; 3]> = (0..100_000).map(|_| [0, 1, 2].map(|_| (next(&mut seed) % 65_536) as f32 * (255.0 / 65_535.0))).collect();
let w = [0.55, 0.77, 0.34];
let start = Instant::now();
let baseline: Vec<_> = queries.iter().map(|&c| linear(&colors, c, w)).collect();
let old = start.elapsed();
let start = Instant::now();
let index = PaletteIndex::new(&colors, w);
let improved: Vec<_> = queries.iter().map(|&c| index.nearest(c)).collect();
let new = start.elapsed();
assert_eq!(baseline, improved);
eprintln!(
"100k nearest-colour queries, 256 entries: linear {old:?}, spatial index {new:?}, speedup {:.2}x",
old.as_secs_f64() / new.as_secs_f64()
);
}
}
+4
View File
@@ -274,6 +274,9 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>
// On whole pixels at full size the cheapest sampler is exact.
let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
let sampler = peniko::ImageSampler { quality, ..Default::default() };
// The raster maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern,
// radial or freeform gradient) would resample it (#1536).
ctx.reset_paint_transform();
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
ctx.set_transform(Affine::IDENTITY);
@@ -617,6 +620,7 @@ impl Renderer {
ctx.set_transform(Affine::translate((x, y)));
ctx.set_blend_mode(blend_mode(mode));
let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() };
ctx.reset_paint_transform();
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));
+15 -1
View File
@@ -96,6 +96,9 @@ pub struct RenderOptions {
/// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge
/// Color), RGB.
pub tile_edge: [u8; 3],
/// Colour and style of the paths made into guides (Preferences › Guides & Grid, as the ruler
/// guides the UI draws): RGB, and whether they are dotted.
pub guide: ([u8; 3], bool),
/// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's
/// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent).
pub mask_view: Option<NodeId>,
@@ -201,6 +204,7 @@ impl Default for RenderOptions {
trim: false,
skip_templates: false,
tile_edge: vectorcraft_doc::LAYER_COLORS[0].1,
guide: ([0x4a, 0xd8, 0xff], false),
mask_view: None,
highlight_substitutions: false,
anti_alias: AntiAlias::Art,
@@ -1158,7 +1162,17 @@ impl Renderer {
NodeKind::Path { path, rule, guide, .. } => {
let bp = path.to_bezpath();
if *guide {
self.hairline(ctx, f, &bp, [0x4a, 0xd8, 0xff, 255]);
let ([r, g, b], dots) = f.opts.guide;
if dots {
let mut screen = bp.clone();
screen.apply_affine(f.view);
ctx.set_transform(Affine::IDENTITY);
ctx.set_stroke(kurbo::Stroke::new(1.5).with_dashes(0.0, [1.5, 2.5]));
ctx.set_paint(f.ink.fixed([r, g, b, 255]));
ctx.stroke_path(&screen);
} else {
self.hairline(ctx, f, &bp, [r, g, b, 255]);
}
} else {
self.draw_shape(ctx, f, n, &bp, *rule);
}
+174 -10
View File
@@ -1,12 +1,12 @@
//! Pattern fills and the pattern-editing preview.
//!
//! A pattern paint is drawn as a repeating image: the tiling's rectangular super-tile
//! ([`PatternDef::period`]) is rasterized once at the current device scale (rounded to a power of
//! two, so it is re-rasterized only when the zoom changes by more than 2×) and used as an image
//! paint with `Extend::Repeat`, with the paint transform mapping pixels → pattern space →
//! document. Tiles are cached per pattern name (and ink plane, see [`crate::ink`]) and invalidated
//! when the definition changes (art `Arc` identity, tile, tile type, overlap) — the pattern's
//! "revision".
//! ([`PatternDef::period`]) is rasterized at the device scale it is painted at, rounded up to a
//! power of two and capped ([`raster_scale`]), and used as an image paint with `Extend::Repeat`,
//! 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".
//!
//! 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.
@@ -28,10 +28,13 @@ use crate::{Frame, RenderOptions, Rendered, Renderer};
/// Largest super-tile raster side (pixels).
const MAX_TILE_PX: f64 = 2048.0;
/// What a tile is kept under: the pattern's name and the bits of its raster scale
/// ([`raster_scale`]).
type TileKey = (String, u64);
struct TileEntry {
art: Vec<Arc<Node>>,
params: (Rect, TileType, Overlap),
scale: f64,
sx: f64,
sy: f64,
pixmap: Arc<Pixmap>,
@@ -39,7 +42,7 @@ struct TileEntry {
struct PatternCache {
/// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out.
tiles: [FrameCache<String, TileEntry>; 4],
tiles: [FrameCache<TileKey, TileEntry>; 4],
renderer: Option<Box<Renderer>>,
depth: u32,
}
@@ -155,11 +158,12 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
let (pw, ph) = def.period();
let scale = raster_scale(s, (pw, ph));
let params = (def.tile, def.tile_type, def.overlap);
let key = (name.to_string(), scale.to_bits());
let hit = CACHE.with(|c| {
let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize];
let t = tiles.tick();
tiles.get(name, t).filter(|e| e.scale == scale && 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)).map(|e| (e.pixmap.clone(), e.sx, e.sy))
});
let (pm, sx, sy) = match hit {
Some(h) => h,
@@ -176,7 +180,7 @@ 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, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() }, bytes, t);
tiles.insert(key, TileEntry { art: def.art.clone(), params, sx, sy, pixmap: pm.clone() }, bytes, t);
});
(pm, sx, sy)
}
@@ -274,6 +278,58 @@ mod tests {
p[0] > 200 && p[1] > 200 && p[2] > 200
}
/// #1536: a raster effect drawn after a pattern fill is the same as without the pattern (the
/// pattern's paint transform must not resample the effect's raster).
#[test]
fn effects_after_a_pattern_fill_keep_their_place_and_size() {
let fx = |id: &str, params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true };
for effect in [
fx("blur.gaussian", serde_json::json!({"radius": 4.0})),
fx("stylize.dropShadow", serde_json::json!({"x": 6, "y": 6, "blur": 3, "opacity": 100})),
] {
let make = |with_pattern: bool| {
let mut d = doc_with_pattern(TileType::Grid);
let l = d.layers[0].id;
let filled = d.children(Some(l)).unwrap()[0].id;
d.remove(filled).unwrap();
if with_pattern {
let id = d.alloc_id();
let n = Node::path(
id,
shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)),
Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0),
);
d.insert(Some(l), 0, n).unwrap();
}
let id = d.alloc_id();
let mut n = Node::path(
id,
shapes::ellipse(Rect::new(120.0, 110.0, 180.0, 170.0)),
Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0),
);
n.appearance.effects.push(effect.clone());
let at = d.children(Some(l)).map_or(0, Vec::len);
d.insert(Some(l), at, n).unwrap();
d
};
// Supersampled, as raster exports draw.
let one = |d: &Document| {
let mut r = Renderer::new();
r.render(
d,
200,
200,
Affine::IDENTITY,
&RenderOptions { background: Some([255, 255, 255, 255]), supersample: true, ..Default::default() },
)
};
let (with, without) = (one(&make(true)), one(&make(false)));
for (x, y) in [(150, 140), (125, 140), (176, 160), (150, 172), (184, 176)] {
assert_eq!(with.pixel(x, y), without.pixel(x, y), "{} at ({x}, {y})", effect.id);
}
}
}
#[test]
fn pattern_fill_is_periodic() {
let d = doc_with_pattern(TileType::Grid);
@@ -327,6 +383,114 @@ mod tests {
assert!(is_white(r.pixel(5, 5)) && is_red(r.pixel(15, 5)));
}
/// `doc_with_pattern(TileType::Grid)` with a 40×40 square on top of its rectangle, filled with
/// the same pattern scaled by `s`, as Transform Pattern Tiles leaves it.
fn doc_with_two_scales(s: f64) -> Document {
let mut d = doc_with_pattern(TileType::Grid);
let square = shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0));
let mut n = Node::path(d.alloc_id(), square, Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0));
vectorcraft_doc::pattern::transform_pattern_paints(&mut n, Affine::scale(s));
let l = d.layers[0].id;
d.insert(Some(l), 1, n).unwrap();
d
}
/// `d` drawn by `r` at `scale`.
fn frame(r: &mut Renderer, d: &Document, scale: f64) -> Rendered {
let px = (200.0 * scale) as u32;
r.render(d, px, px, Affine::scale(scale), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
/// The tiles rasterized on this thread so far: the tile renderer counts a frame for each.
fn tiles_rasterized() -> u64 {
CACHE.with(|c| c.borrow().renderer.as_ref().map_or(0, |r| r.stamp))
}
#[test]
fn objects_at_two_pattern_scales_keep_their_tiles_between_frames() {
// At 100% the rectangle's tile is rasterized at scale 1, and the square's tile is
// rasterized at 0.25, or at 2 when its tiles are scaled to 101%.
for s in [0.25, 1.01] {
let d = doc_with_two_scales(s);
let mut r = Renderer::new();
let start = tiles_rasterized();
let first = frame(&mut r, &d, 1.0).pixels;
assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: a tile for each object");
for _ in 0..3 {
assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}");
}
assert_eq!(tiles_rasterized() - start, 2, "tiles scaled by {s}: later frames draw both objects from their tiles");
// After a frame at 200%, the next frame at 100% draws from the tiles made at 100%.
frame(&mut r, &d, 2.0);
let zoomed = tiles_rasterized();
assert!(frame(&mut r, &d, 1.0).pixels == first, "tiles scaled by {s}");
assert_eq!(tiles_rasterized(), zoomed, "tiles scaled by {s}: back at 100%");
}
}
#[test]
fn kept_tiles_draw_what_a_new_renderer_draws() {
// A new renderer on a thread of its own starts with no tiles.
let fresh = |d: &Document, zoom: f64| {
let d = d.clone();
std::thread::spawn(move || frame(&mut Renderer::new(), &d, zoom).pixels).join().unwrap()
};
let mut d = doc_with_two_scales(0.25);
let mut r = Renderer::new();
// Pixel (130, 130) is red in the square's tiles and clear in the rectangle's, (133, 133) is
// clear in both, and (103, 103), outside the square, is red in the rectangle's.
let first = frame(&mut r, &d, 1.0);
assert!(is_red(first.pixel(130, 130)) && is_white(first.pixel(133, 133)) && is_red(first.pixel(103, 103)));
for zoom in [1.0, 2.0, 1.0, 0.5, 2.0, 1.0] {
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "zoom {zoom}");
}
// Change the tile type as Pattern Options does.
d.patterns[0].tile_type = TileType::BrickByRow { offset: 0.5 };
for zoom in [2.0, 1.0] {
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "brick, zoom {zoom}");
}
// Replace the art as Done Editing Pattern does.
let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
d.patterns[0].art = vec![Arc::new(Node::path(NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), blue))];
for zoom in [1.0, 2.0] {
assert!(frame(&mut r, &d, zoom).pixels == fresh(&d, zoom), "new art, zoom {zoom}");
}
}
#[test]
fn kept_tiles_stay_within_the_budget() {
// The rectangle's tile is 20 pixels a side per 100% of zoom, and the square's tile is 5
// pixels a side per 100% of zoom. The budget holds the rectangle's tile at 400% and both
// tiles at 200%; the tiles drawn from 100% to 400% need more.
let d = doc_with_two_scales(0.25);
let tile = |side: usize| size_of::<TileEntry>() + side * side * 4;
let budget = tile(80) + tile(40) + tile(10);
CACHE.with(|c| c.borrow_mut().tiles[Ink::Display as usize] = FrameCache::new(budget, usize::MAX));
let kept = || {
CACHE.with(|c| {
let c = c.borrow();
let tiles = &c.tiles[Ink::Display as usize];
(tiles.len(), tiles.bytes())
})
};
let mut r = Renderer::new();
for zoom in [1.0, 2.0, 4.0] {
frame(&mut r, &d, zoom);
let drawn = tiles_rasterized();
frame(&mut r, &d, zoom);
assert_eq!(tiles_rasterized(), drawn, "zoom {zoom}: the next frame draws from the tiles just made");
let (len, bytes) = kept();
assert!(len >= 2 && bytes <= budget, "zoom {zoom}: {len} tiles, {bytes} bytes");
}
// Tiles were rasterized at five scales (0.25, 0.5, 1, 2 and 4); the cache dropped the least
// recently used tiles first.
assert!(kept().0 < 5, "{:?}", kept());
let before = tiles_rasterized();
frame(&mut r, &d, 1.0);
assert!(tiles_rasterized() > before, "a tile of 100% was dropped");
assert!(kept().1 <= budget, "{:?}", kept());
}
#[test]
fn missing_pattern_is_grey() {
let mut d = doc_with_pattern(TileType::Grid);
+36
View File
@@ -467,3 +467,39 @@ fn supersampled_art_leaves_no_seam_between_shapes_that_meet() {
assert_eq!(supersample_factor(5000, 5000), None);
assert_eq!(supersample_factor(40_000, 10), None);
}
/// #1613: a zero-length subpath (`m` then `L` to the same point, as `.ai` files write dots) is a
/// dot as wide as the stroke: a disc under a round cap, a square under a projecting one, nothing
/// under a butt cap (PDF 32000-1 8.5.3.2, SVG 2 "Path data" zero-length segments).
#[test]
fn zero_length_subpaths_draw_dots_with_their_cap() {
let dots = |cap: vectorcraft_doc::LineCap| {
let mut d = Document::new(100.0, 100.0);
let mut bp = BezPath::new();
for at in [(30.0, 50.0), (70.0, 50.0)] {
bp.move_to(at);
bp.line_to(at);
}
let id = d.alloc_id();
let mut n = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0));
n.appearance.stroke_mut().unwrap().cap = cap;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
render(&d)
};
let (black, white) = ([0, 0, 0, 255], [255, 255, 255, 255]);
let round = dots(vectorcraft_doc::LineCap::Round);
for x in [30, 70] {
assert_eq!(round.pixel(x, 50), black, "round dot at {x}");
assert_eq!(round.pixel(x + 2, 52), black, "inside the round dot at {x}");
assert_eq!(round.pixel(x + 4, 54), white, "the round dot's corner at {x}");
}
let square = dots(vectorcraft_doc::LineCap::Square);
for x in [30, 70] {
assert_eq!(square.pixel(x, 50), black, "square dot at {x}");
assert_eq!(square.pixel(x + 4, 54), black, "the square dot's corner at {x}");
assert_eq!(square.pixel(x + 6, 50), white, "past the square dot at {x}");
}
let butt = dots(vectorcraft_doc::LineCap::Butt);
assert_eq!(butt.pixels.iter().filter(|&&p| p != 255).count(), 0, "a butt cap draws no dot");
}
+28
View File
@@ -67,3 +67,31 @@ fn swatch_bounds_outline_the_period_dashed() {
let off = render(&editing(tt, false, false), Some([0, 200, 0]));
assert!((51..70).all(|x| is_white(off.pixel(x, 90))));
}
/// #1534: a path made into a guide is drawn in the guide colour and style (Preferences › Guides &
/// Grid), as ruler guides are: a line, or dots with gaps between them.
#[test]
fn guide_paths_take_the_guide_color_and_style() {
let mut d = Document::new(200.0, 200.0);
let (layer, id) = (d.layers[0].id, d.alloc_id());
let mut n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 60.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { guide, .. } = &mut n.kind {
*guide = true;
}
d.insert(Some(layer), 0, n).unwrap();
let draw = |guide| {
let opts = RenderOptions { background: Some([255; 4]), guide, ..Default::default() };
Renderer::new().render(&d, 120, 120, Affine::translate((50.5, 50.5)), &opts)
};
// Along the top edge of the square (y = 50), between its corners: the pixels the red covers.
let inked = |r: &Rendered| (55..105).filter(|x| r.pixel(*x, 50)[1] < 64).count();
let line = draw(([200, 0, 0], false));
let p = line.pixel(80, 50);
assert!(p[0] > 150 && p[1] < 100 && p[2] < 100, "{p:?}");
assert_eq!(inked(&line), 50);
let dotted = draw(([200, 0, 0], true));
let dots = inked(&dotted);
assert!((10..40).contains(&dots), "{dots}: {:?}", (45..70).map(|x| dotted.pixel(x, 50)[1]).collect::<Vec<_>>());
let default = draw(RenderOptions::default().guide);
assert!((55..105).all(|x| !is_white(default.pixel(x, 50))), "a line by default");
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "vectorcraft-scriptcheck"
description = "Static checker for scripts written for the reference app: what a script uses, without running it"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dev-dependencies]
proptest = { workspace = true }
File diff suppressed because it is too large. Load diff
+179
View File
@@ -0,0 +1,179 @@
//! The names the checker looks for, each list with the public page it comes from, and the need each
//! feature sets. Only names are taken from these pages; no description or example is copied.
use crate::Need;
// The preprocessor directives, written `#name` or `//@name`:
// https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/
pub(crate) const DIRECTIVES: &[&str] = &["include", "includepath", "script", "strict", "target", "targetengine"];
// The `$` object's environment and file functions:
// https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/
pub(crate) const DOLLAR: &str = "$";
pub(crate) const DOLLAR_ENV: &[&str] = &["getenv", "setenv"];
pub(crate) const DOLLAR_EVAL_FILE: &str = "evalFile";
// https://extendscript.docsforadobe.dev/extendscript-tools-features/user-notification-dialogs/
pub(crate) const NOTIFY: &[&str] = &["alert", "confirm", "prompt"];
// https://extendscript.docsforadobe.dev/extendscript-tools-features/localizing-extendscript-strings/
pub(crate) const LOCALIZE: &str = "localize";
// https://extendscript.docsforadobe.dev/extendscript-tools-features/specifying-measurement-values/
pub(crate) const UNIT_VALUE: &str = "UnitValue";
// File and Folder objects, their functions and their open modes:
// https://extendscript.docsforadobe.dev/file-system-access/file-object/
// https://extendscript.docsforadobe.dev/file-system-access/folder-object/
pub(crate) const FILE: &str = "File";
pub(crate) const FOLDER: &str = "Folder";
pub(crate) const FILE_READS: &[&str] = &["read", "readch", "readln"];
pub(crate) const FOLDER_READS: &[&str] = &["getFiles"];
pub(crate) const FILE_WRITES: &[&str] = &["write", "writeln", "remove", "rename", "copy", "createAlias", "create"];
pub(crate) const FILE_DIALOGS: &[&str] = &["openDialog", "saveDialog"];
pub(crate) const FOLDER_DIALOGS: &[&str] = &["selectDialog"];
pub(crate) const INSTANCE_DIALOGS: &[&str] = &["openDlg", "saveDlg", "selectDlg"];
pub(crate) const FOLDER_PLACES: &[&str] =
&["appData", "appPackage", "commonFiles", "current", "desktop", "myDocuments", "startup", "system", "temp", "trash", "userData"];
pub(crate) const EXECUTE: &str = "execute";
pub(crate) const OPEN: &str = "open";
/// The open modes that write: write, append and edit.
pub(crate) const WRITE_MODES: &[&str] = &["w", "a", "e"];
// Windows and their types, and drawing:
// https://extendscript.docsforadobe.dev/user-interface-tools/window-object/
// https://extendscript.docsforadobe.dev/user-interface-tools/resource-specifications/
// https://extendscript.docsforadobe.dev/user-interface-tools/graphic-customization-objects/
pub(crate) const WINDOW: &str = "Window";
pub(crate) const ON_DRAW: &str = "onDraw";
pub(crate) const DRAWING: &[&str] = &["newPen", "newBrush"];
// https://extendscript.docsforadobe.dev/interapplication-communication/bridgetalk-message-object/
pub(crate) const BRIDGE_TALK: &str = "BridgeTalk";
// https://extendscript.docsforadobe.dev/external-communication/socket-object/
pub(crate) const SOCKET: &str = "Socket";
// https://extendscript.docsforadobe.dev/integrating-external-libraries/externalobject-object/
pub(crate) const EXTERNAL_OBJECT: &str = "ExternalObject";
// https://extendscript.docsforadobe.dev/scripting-xmp/xmpscript-object-reference/
pub(crate) const XMP: &[&str] = &["XMPMeta", "XMPFile", "XMPUtils", "XMPConst", "XMPDateTime"];
// https://extendscript.docsforadobe.dev/integrating-xml/the-xml-object/
pub(crate) const XML: &[&str] = &["XML", "XMLList"];
// The operators a script can define for its own classes:
// https://extendscript.docsforadobe.dev/extendscript-tools-features/operator-overloading/
pub(crate) const OPERATORS: &[&str] = &["+", "-", "*", "/", "%", "^", "<", "<=", "==", "<<", ">>", ">>>", "&", "|", "===", "~"];
// https://extendscript.docsforadobe.dev/extendscript-tools-features/extendscript-reflection-interface/
pub(crate) const REFLECT: &str = "reflect";
// The roots of the document model: https://ai-scripting.docsforadobe.dev/jsobjref/Application/
pub(crate) const DOM_ROOTS: [&str; 4] = ["app", "activeDocument", "documents", "selection"];
// Application methods that page doesn't list, named in public forum posts:
// https://community.adobe.com/t5/illustrator-discussions/changes-in-cs6/td-p/4173362 (executeMenuCommand,
// doScript, loadAction, unloadAction) and
// https://community.adobe.com/t5/illustrator-discussions/what-s-new-in-illustrator-scripting-cc2020/m-p/10773439
// (selectTool).
pub(crate) const MENU_COMMAND: &str = "executeMenuCommand";
pub(crate) const ACTIONS: &[&str] = &["doScript", "loadAction", "unloadAction"];
pub(crate) const SELECT_TOOL: &str = "selectTool";
/// The application methods above, which the report lists as features, not as member chains.
pub(crate) const APP_METHODS: &[&str] = &["executeMenuCommand", "doScript", "loadAction", "unloadAction", "selectTool"];
/// Text an action set written out at run time holds.
pub(crate) const ACTION_TEXT: &str = "/actionCount";
/// Constructors of the language (ECMA-262 3rd edition, section 15) and of E4X (ECMA-357 2nd edition,
/// section 13), and the platform's own: `new` of these is no document-model type.
pub(crate) const CONSTRUCTORS: &[&str] = &[
"Object",
"Function",
"Array",
"String",
"Boolean",
"Number",
"Date",
"RegExp",
"Error",
"EvalError",
"RangeError",
"ReferenceError",
"SyntaxError",
"TypeError",
"URIError",
"Namespace",
"QName",
"XML",
"XMLList",
"File",
"Folder",
"Window",
"Socket",
"BridgeTalk",
"ExternalObject",
"UnitValue",
"XMPMeta",
"XMPFile",
"XMPDateTime",
];
/// Every feature id a report can list, in the order the report lists them.
pub const FEATURES: &[&str] = &[
"actions",
"bridgeTalk",
"directive.include",
"directive.other",
"directive.target",
"directive.targetengine",
"dollar",
"dollar.env",
"dollar.evalFile",
"dynamicCode",
"e4x",
"externalObject",
"file.dialog",
"file.execute",
"file.objects",
"file.places",
"file.read",
"file.write",
"hostCheck",
"localize",
"menuCommand",
"notify",
"operatorOverloading",
"preferences",
"reflection",
"scriptui.dialog",
"scriptui.drawing",
"scriptui.palette",
"scriptui.window",
"selectTool",
"socket",
"unitValue",
"xml",
"xmp",
];
/// The need feature `id` sets.
pub(crate) fn need(id: &str) -> Option<Need> {
Some(match id {
"directive.target" | "directive.include" | "directive.other" | "dollar" | "dynamicCode" | "notify" | "localize" | "unitValue"
| "preferences" | "hostCheck" => Need::Language,
"dollar.env" | "dollar.evalFile" | "file.objects" | "file.read" | "file.write" | "file.dialog" | "file.places" | "file.execute" => {
Need::Files
}
"menuCommand" => Need::MenuCommands,
"scriptui.dialog" | "scriptui.window" | "scriptui.drawing" => Need::Dialogs,
"directive.targetengine"
| "scriptui.palette"
| "bridgeTalk"
| "socket"
| "externalObject"
| "xmp"
| "xml"
| "e4x"
| "operatorOverloading"
| "reflection"
| "actions"
| "selectTool" => Need::HostOnly,
_ => return None,
})
}
+87
View File
@@ -0,0 +1,87 @@
//! Preprocessor lines (`#target acme-12`, `//@include "lib.jsxinc"`): their names and values, the
//! parts of a target's application specifier, and the folders an `#includepath` line names.
use crate::catalog;
/// A preprocessor line.
pub(crate) struct Line<'s> {
/// `#` or `//@`.
pub form: &'static str,
/// The name as written.
pub name: &'s str,
/// The documented directive the name is (compared without case), if it is one.
pub known: Option<&'static str>,
/// Its value: the text after the name, without quotes around it, or else without a trailing
/// `//` comment and `;`.
pub value: &'s str,
}
/// The line `text`, which starts with `#` or `//@`.
pub(crate) fn parse(text: &str) -> Line<'_> {
let (form, rest) = match text.strip_prefix("//@") {
Some(rest) => ("//@", rest),
None => ("#", text.strip_prefix('#').unwrap_or(text)),
};
let end = rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len());
let (name, rest) = (rest.get(..end).unwrap_or(rest), rest.get(end..).unwrap_or_default());
let known = catalog::DIRECTIVES.iter().copied().find(|d| d.eq_ignore_ascii_case(name));
Line { form, name, known, value: value(rest) }
}
fn value(rest: &str) -> &str {
let v = rest.trim();
if let Some(quote) = v.chars().next().filter(|c| matches!(c, '"' | '\'')) {
let inner = v.get(1..).unwrap_or_default();
return inner.find(quote).and_then(|i| inner.get(..i)).unwrap_or(inner);
}
let v = v.find("//").and_then(|i| v.get(..i)).unwrap_or(v);
v.trim_end().trim_end_matches(';').trim_end()
}
/// The parts of an application specifier, `name_instance-version-locale`, every part after the name
/// optional, as
/// https://extendscript.docsforadobe.dev/interapplication-communication/application-and-namespace-specifiers/
/// lays it out.
pub(crate) struct Specifier<'s> {
pub app: &'s str,
pub instance: Option<&'s str>,
pub version: Option<&'s str>,
pub locale: Option<&'s str>,
}
pub(crate) fn target(value: &str) -> Specifier<'_> {
let (head, tail) = match value.split_once('-') {
Some((head, tail)) => (head, Some(tail)),
None => (value, None),
};
let (app, instance) = match head.split_once('_') {
Some((app, instance)) => (app, Some(instance)),
None => (head, None),
};
let (version, locale) = match tail.map(|t| t.split_once('-').map_or((t, None), |(v, l)| (v, Some(l)))) {
Some((version, locale)) => (Some(version), locale),
None => (None, None),
};
fn part(s: Option<&str>) -> Option<&str> {
s.filter(|s| !s.is_empty())
}
Specifier { app, instance: part(instance), version: part(version), locale: part(locale) }
}
/// The most folders an `#includepath` line names that are kept.
const MAX_PATHS: usize = 64;
/// The longest `#include` name or `#includepath` folder followed, in bytes.
pub(crate) const MAX_PATH: usize = 4096;
/// The folders an `#includepath` value names, separated by `;`: the first [`MAX_PATHS`] of those
/// no longer than [`MAX_PATH`].
pub(crate) fn include_paths(value: &str) -> Vec<String> {
value
.split(';')
.map(|p| p.trim().trim_matches(['"', '\'']))
.filter(|p| !p.is_empty() && p.len() <= MAX_PATH)
.take(MAX_PATHS)
.map(str::to_string)
.collect()
}
+296
View File
@@ -0,0 +1,296 @@
//! Following `#include` lines: the file each one names, found inside the script's folder and read
//! through the caller's [`Loader`].
//!
//! The preprocessor page (https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)
//! says that a name is looked for as given and then in each `#includepath` folder in turn, that a
//! name starting with `/` is absolute, and that `JSINCLUDE` is a fallback; it doesn't say which
//! folder a relative name starts from. The checker takes a name, and an `#includepath` folder, from
//! the folder of the file the line is in. It never follows an absolute name, a name or folder that
//! leads out of the script's folder (`..`), a name or folder longer than [`MAX_PATH`] bytes, or
//! `JSINCLUDE`. Each file is read once. A queue holds the files still to follow, in the order they
//! were read, and nothing recurses: each file is reached first at its smallest depth. At most
//! [`crate::Limits::lookups`] paths are looked up. Once every file is read, an
//! `#include` line is a cycle when the file it names includes the file the line is in, directly or
//! through other files.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::rc::Rc;
use crate::analyze::{self, IncludeRef, Known};
use crate::directives::MAX_PATH;
use crate::report::{Acc, IncludeStatus};
/// An included file that was read and is checked: its path inside the script's folder, its text
/// (and whether it held bytes that aren't UTF-8), and the `#includepath` folders in force at its
/// start.
pub(crate) struct ReadFile {
pub path: String,
pub text: String,
pub invalid: bool,
pub paths: Rc<[String]>,
}
/// What a [`Loader`] found at a path.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Loaded {
/// The file's first bytes (at most the `max` asked for).
Bytes(Vec<u8>),
/// No file there, or it can't be read.
Missing,
/// A file the caller may not read (outside the folders automation may read).
Refused,
}
/// Where the files a script includes come from: the folder the script is in.
pub trait Loader {
/// The first `max` bytes of the file at `path`, a path inside the script's folder with `/`
/// between its names (`lib/util.jsxinc`).
fn load(&mut self, path: &str, max: usize) -> Loaded;
}
/// Follow the script's `includes`: `script` is its file name and `bytes` its size. Each file read
/// is scanned here for its own includes and for what its variables hold, with its tallies left
/// out; it starts with what `known` holds, which then gains what the file adds → the files to
/// check, in the order they were read.
pub(crate) fn follow(
acc: &mut Acc,
includes: Vec<IncludeRef>,
loader: &mut dyn Loader,
script: &str,
bytes: usize,
known: &mut Known,
) -> Vec<ReadFile> {
/// The `#include` lines of a file that was read, still to follow.
struct Work {
/// The file the lines are in: its index in `files` (0: the script).
file: usize,
includes: Vec<IncludeRef>,
depth: usize,
}
/// An `#include` line as the report lists it.
struct Row {
value: String,
line: usize,
/// The file the line is in.
from: usize,
status: IncludeStatus,
path: Option<String>,
/// The file it names, when that file was read.
to: Option<usize>,
}
let limits = acc.limits;
// The files read, by their path inside the script's folder; the script first.
let mut files: Vec<String> = vec![script.to_string()];
// What became of each path found, and the file read there: a file is read once.
let mut read: BTreeMap<String, (IncludeStatus, Option<usize>)> = BTreeMap::from([(script.to_string(), (IncludeStatus::Checked, Some(0)))]);
// The files still to follow, in the order they were read: each file is reached first at its
// smallest depth.
let mut work = VecDeque::from([Work { file: 0, includes, depth: 0 }]);
let mut rows: Vec<Row> = vec![];
// Which file includes which.
let mut edges: BTreeSet<(usize, usize)> = BTreeSet::new();
// The bytes read and the included files read, those not checked too, and the paths looked up.
let (mut total, mut loaded, mut lookups) = (bytes, 0usize, 0usize);
// The files to check.
let mut checked: Vec<ReadFile> = vec![];
while let Some(w) = work.pop_front() {
let folder = if w.file == 0 { String::new() } else { files.get(w.file).map_or("", |f| parent(f)).to_string() };
for inc in w.includes {
acc.steps = acc.steps.saturating_add(1);
let mut status = IncludeStatus::NotFollowed;
let (mut found, mut to): (Option<String>, Option<usize>) = (None, None);
let mut refused = false;
for path in candidates(&folder, &inc.value, &inc.paths) {
acc.steps = acc.steps.saturating_add(1);
if let Some(&(before, file)) = read.get(&path) {
(status, found, to) = (before, Some(path), file);
break;
}
if w.depth + 1 > limits.include_depth || loaded >= limits.includes || total >= limits.total_bytes || lookups >= limits.lookups {
(status, found) = (IncludeStatus::Limit, None);
break;
}
lookups += 1;
status = IncludeStatus::Missing;
// One byte more than a file or the rest of the total may take shows that it is too large.
let max = limits.file_bytes.min(limits.total_bytes.saturating_sub(total)).saturating_add(1);
let b = match loader.load(&path, max) {
Loaded::Bytes(b) => b,
Loaded::Missing => continue,
Loaded::Refused => {
refused = true;
continue;
}
};
// Every file read counts, also one that is too large, compiled or not text.
loaded += 1;
total = total.saturating_add(b.len());
acc.steps = acc.steps.saturating_add(b.len());
status = if b.len() > limits.file_bytes || total > limits.total_bytes {
IncludeStatus::Limit
} else if crate::is_compiled(&path, &b) || !crate::is_text(&b) {
IncludeStatus::Unreadable
} else {
IncludeStatus::Checked
};
if status != IncludeStatus::Checked {
read.insert(path.clone(), (status, None));
found = Some(path);
break;
}
files.push(path.clone());
let file = files.len() - 1;
read.insert(path.clone(), (status, Some(file)));
let (text, invalid) = crate::decode(&b);
let mut tallies = Acc::new(limits);
let scan = analyze::scan(&text, Some(&path), Rc::clone(&inc.paths), known, &mut tallies);
acc.steps = acc.steps.saturating_add(tallies.steps);
known.add(scan.known, limits.entries);
checked.push(ReadFile { path: path.clone(), text: text.into_owned(), invalid, paths: Rc::clone(&inc.paths) });
work.push_back(Work { file, includes: scan.includes, depth: w.depth + 1 });
(found, to) = (Some(path), Some(file));
break;
}
if status == IncludeStatus::Missing && refused {
status = IncludeStatus::NotFollowed;
}
if let Some(to) = to {
edges.insert((w.file, to));
}
// One row more than the report keeps marks it truncated.
if rows.len() <= limits.entries {
rows.push(Row { value: acc.echo(&inc.value), line: inc.line, from: w.file, status, path: found, to });
}
}
}
let reach = reachable(files.len(), &edges, acc);
for r in rows {
let cycle = r.to.and_then(|to| reach.get(to)).and_then(|reached| reached.get(r.from)).copied().unwrap_or(false);
let status = if cycle { IncludeStatus::Cycle } else { r.status };
let file = files.get(r.from).filter(|_| r.from > 0).map(String::as_str);
acc.include(&r.value, r.line, file, status, r.path.as_deref());
}
checked
}
/// For each of `n` files, the files it includes directly or through other files (itself too),
/// with `edges` giving the files each one includes.
fn reachable(n: usize, edges: &BTreeSet<(usize, usize)>, acc: &mut Acc) -> Vec<Vec<bool>> {
let mut next: Vec<Vec<usize>> = vec![vec![]; n];
for &(from, to) in edges {
if let Some(v) = next.get_mut(from) {
v.push(to);
}
}
(0..n)
.map(|start| {
let mut seen = vec![false; n];
let mut stack = vec![start];
while let Some(f) = stack.pop() {
acc.steps = acc.steps.saturating_add(1);
match seen.get_mut(f) {
Some(s) if !*s => *s = true,
_ => continue,
}
stack.extend(next.get(f).into_iter().flatten().copied());
}
seen
})
.collect()
}
/// List the script's `includes` as not read.
pub(crate) fn not_read(acc: &mut Acc, includes: Vec<IncludeRef>) {
for inc in includes {
acc.include(&inc.value, inc.line, None, IncludeStatus::NotRead, None);
}
}
/// The folder part of `path` (`lib/a.jsxinc` → `lib`, `a.jsxinc` → ``).
fn parent(path: &str) -> &str {
path.rsplit_once('/').map_or("", |(folder, _)| folder)
}
/// A name the checker doesn't follow: absolute (`/x`, `\x`, `C:x`, `~/x`), naming an environment
/// variable (`%APPDATA%`, `$HOME`), or longer than [`MAX_PATH`].
fn unfollowed(name: &str) -> bool {
name.starts_with(['/', '\\', '~', '$']) || name.contains([':', '%']) || name.len() > MAX_PATH
}
/// `rel` taken from `folder`, both inside the script's folder, as a path from that folder (empty
/// for the folder itself): none when it leads out of it.
fn walk(folder: &str, rel: &str) -> Option<String> {
let mut parts: Vec<&str> = folder.split('/').filter(|s| !s.is_empty()).collect();
for part in rel.split(['/', '\\']) {
match part {
"" | "." => {}
".." => {
parts.pop()?;
}
p => parts.push(p),
}
}
Some(parts.join("/"))
}
/// The file `rel` names from `folder`, both inside the script's folder: none when it leads out of
/// it, or names the folder itself.
fn join(folder: &str, rel: &str) -> Option<String> {
walk(folder, rel).filter(|path| !path.is_empty())
}
/// The paths an `#include` of `name` may mean, from `folder`, in the order they are tried and each
/// once: the name itself, then the name in each `#includepath` folder. Each is worked out when it
/// is tried. Names and folders the checker doesn't follow, and names or folders that lead out of
/// the script's folder, give none.
fn candidates<'a>(folder: &'a str, name: &'a str, paths: &'a [String]) -> impl Iterator<Item = String> + 'a {
let name = Some(name).filter(|n| !unfollowed(n));
let first = name.and_then(|n| join(folder, n));
let inside = name
.into_iter()
.flat_map(move |n| paths.iter().filter(|p| !unfollowed(p)).filter_map(move |p| walk(folder, p)).filter_map(move |p| join(&p, n)));
let mut tried: Vec<String> = vec![];
first.into_iter().chain(inside).filter(move |p| {
let new = !tried.contains(p);
if new {
tried.push(p.clone());
}
new
})
}
#[cfg(test)]
mod tests {
use super::*;
fn tried(folder: &str, name: &str, paths: &[String]) -> Vec<String> {
candidates(folder, name, paths).collect()
}
#[test]
fn names_are_taken_from_the_including_files_folder() {
assert_eq!(tried("", "a.jsxinc", &[]), ["a.jsxinc"]);
assert_eq!(tried("lib", "b.jsxinc", &[]), ["lib/b.jsxinc"]);
assert_eq!(tried("lib/deep", "../c.jsxinc", &[]), ["lib/c.jsxinc"]);
assert_eq!(tried("lib", "./x\\y.jsxinc", &[]), ["lib/x/y.jsxinc"]);
// Then each include path, inside the folder.
let paths = ["shared".to_string(), "/abs".to_string(), "../out".to_string(), "shared".to_string()];
assert_eq!(tried("", "u.jsxinc", &paths), ["u.jsxinc", "shared/u.jsxinc"]);
// An include path may lead back to the script's own folder.
assert_eq!(tried("lib", "shared.jsxinc", &["..".to_string()]), ["lib/shared.jsxinc", "shared.jsxinc"]);
}
#[test]
fn absolute_names_and_names_leading_out_are_not_followed() {
for name in
["/etc/x.jsx", "\\\\server\\x.jsx", "C:/x.jsx", "~/x.jsx", "%APPDATA%/x.jsx", "$HOME/x.jsx", "../x.jsx", "a/../../x.jsx", ".", "a/.."]
{
assert!(tried("", name, &[]).is_empty(), "{name}");
}
assert!(tried("lib", "../../x.jsx", &[]).is_empty());
// Names and folders longer than a path can be.
let long = "d".repeat(MAX_PATH + 1);
assert!(tried("", &long, &[]).is_empty());
assert_eq!(tried("", "a.jsxinc", &[long]), ["a.jsxinc"]);
}
}
+849
View File
@@ -0,0 +1,849 @@
//! The lexer: script text into tokens that cover it exactly once, blanks, line breaks and comments
//! included. It reads ECMAScript 3 with E4X XML literals, the preprocessor lines (`#name …` at the
//! start of a line, and `//@name …` comments there that name a directive), and the later syntax a
//! script may hold (template literals, `=>`, `?.`), which the analysis reports. An XML literal's
//! markup gives tokens of its own, and the code in its braces gives the same tokens as code anywhere
//! else. Each step moves forward through the text, and nothing recurses: an XML literal's elements
//! are counted, and the open parentheses, the open braces, the `?` of conditionals at each bracket
//! level and the levels of XML literals and their code are kept on stacks capped at the nesting
//! limit.
use crate::catalog;
use crate::report::{TOO_DEEP, UNTERMINATED_COMMENT, UNTERMINATED_REGEXP, UNTERMINATED_STRING, UNTERMINATED_TEMPLATE};
/// What a token is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum T {
/// Blanks: spaces, tabs and other white space.
Space,
/// One line break: LF, CR LF, CR, U+2028 or U+2029.
Newline,
/// A `//` or `/* */` comment.
Comment,
/// A preprocessor line: `#name …` at the start of a line, or a `//@name …` comment there that
/// names a directive.
Directive,
Ident,
Number,
/// A `'…'` or `"…"` string literal.
Str,
/// A `` `…` `` template literal.
Template,
RegExp,
/// The start of an E4X XML literal (`<a b="1">…</a>`, `<>…</>`, `<!-- … -->`): its markup up to
/// its end, or up to and with the `{` of the code in it.
Xml,
/// More of an XML literal's markup: from the `}` that ends the code in it up to the literal's
/// end or the next `{`.
XmlMore,
Punct,
/// A character no token starts with, or `#` and a name away from the start of a line.
Other,
}
/// A token: what it is, its text and the line it starts on (from 1), with the problem it has.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Token<'s> {
pub kind: T,
pub text: &'s str,
pub line: usize,
pub problem: Option<&'static str>,
/// A statement may start at the token (a `function` there is a declaration).
pub stmt: bool,
/// An expression may start at the token (a `/` there starts a regular expression).
pub expr: bool,
/// The byte offset in the text where the token starts.
pub start: usize,
}
/// The kinds of line break the text has used.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct Breaks {
pub lf: bool,
pub crlf: bool,
pub cr: bool,
}
/// Punctuators, longest first: ECMAScript 3's, E4X's (`..`, `.@`, `::`, `@`) and later ones (`=>`,
/// `...`, `?.`, `??`, `**` and the assignments built on them), which the analysis reports.
const PUNCTS: &[&str] = &[
">>>=", "===", "!==", ">>>", "<<=", ">>=", "**=", "...", "&&=", "||=", "??=", "==", "!=", "<=", ">=", "&&", "||", "++", "--", "+=", "-=", "*=",
"/=", "%=", "&=", "|=", "^=", "<<", ">>", "=>", "**", "?.", "??", "::", "..", ".@", "{", "}", "(", ")", "[", "]", ";", ",", "<", ">", "+", "-",
"*", "/", "%", "&", "|", "^", "!", "~", "?", ":", "=", ".", "@",
];
pub(crate) struct Lexer<'s> {
src: &'s str,
pos: usize,
line: usize,
/// Only blanks since the last line break (or the start): a `#` here starts a preprocessor line.
line_start: bool,
/// An expression may start here: a `/` starts a regular expression and a `<` an XML literal.
expr: bool,
/// A statement may start here: a `{` here opens a block. A `{` where only an expression may
/// start opens an object literal.
stmt: bool,
/// The last significant token was `return`, `throw`, `break` or `continue`: a line break here
/// ends the statement (ECMA-262 3rd edition, section 7.9.1).
restricted: bool,
/// The last significant token was `.`, `..`, `.@` or `::`: a name here is a property's, also when
/// it is a keyword such as `return`.
member: bool,
/// The last significant token was `if`, `while`, `for`, `with` or the `each` of `for each`: a
/// `(` here opens a condition.
cond: bool,
/// For each `(` still open, whether it opened a condition, up to `nesting` of them; `deeper`
/// counts the ones past that.
parens: Vec<bool>,
deeper: usize,
/// For each `{` still open, what it opened, up to `nesting` of them; `deeper_braces` counts the
/// ones past that.
braces: Vec<Brace>,
deeper_braces: usize,
/// `function` was read and its body's `{` is still to come: whether the function is an
/// expression (it starts where no statement may).
function: Option<bool>,
/// For the text outside any bracket and each bracket still open, how many `?` of a conditional
/// (`? :`) wait for their `:` there, up to `nesting` levels; `deeper_questions` counts the
/// levels past that.
questions: Vec<usize>,
deeper_questions: usize,
/// The XML literal being read, its innermost level last: its markup, the code in a `{…}` in
/// it, an XML literal in that code, and so on. Empty outside an XML literal.
xml: Vec<Level>,
/// The line the outermost XML literal being read starts on.
xml_line: usize,
/// The most open parentheses kept, and the most levels in `xml`.
nesting: usize,
/// The line of the XML literal the text ended in, when it ended in one.
pub unclosed_xml: Option<usize>,
pub breaks: Breaks,
/// One per character read (and per token): the work done.
pub steps: usize,
}
fn is_break(c: char) -> bool {
matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')
}
fn is_blank(c: char) -> bool {
!is_break(c) && (c.is_whitespace() || c == '\u{feff}')
}
fn ident_start(c: char) -> bool {
c == '$' || c == '_' || c.is_ascii_alphabetic() || (!c.is_ascii() && c.is_alphabetic())
}
/// A character a name may hold after its first.
pub(crate) fn ident_part(c: char) -> bool {
ident_start(c) || c.is_ascii_digit() || (!c.is_ascii() && c.is_alphanumeric())
}
/// After `<`, the start of an XML literal: a name, `>` (a list), `!` (a comment or CDATA), `?` (a
/// processing instruction) or `{` (a computed name).
fn xml_start(c: char) -> bool {
c.is_ascii_alphabetic() || matches!(c, '_' | '>' | '!' | '?' | '{')
}
/// Whether an expression may start after a significant token. After a `}`, the brace's kind decides
/// it ([`Lexer::brace`]), after an XML literal's markup [`Lexer::markup_token`] sets it, and after a
/// property's name, a keyword included ([`Lexer::member`]), none starts.
pub(crate) fn expr_after(kind: T, text: &str) -> bool {
match kind {
T::Ident => matches!(text, "return" | "typeof" | "instanceof" | "in" | "new" | "delete" | "void" | "throw" | "case" | "do" | "else"),
T::Number | T::Str | T::Template | T::RegExp => false,
T::Punct => !matches!(text, ")" | "]" | "++" | "--"),
_ => true,
}
}
/// `rest`, the text after `//@`, starts with a directive's name.
fn names_directive(rest: &str) -> bool {
let end = rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len());
let name = rest.get(..end).unwrap_or_default();
catalog::DIRECTIVES.iter().any(|d| d.eq_ignore_ascii_case(name))
}
/// Where in its markup an XML literal is read.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum At {
/// The `<` of a tag, comment, CDATA section or processing instruction.
Markup,
/// A start tag (`end` false) or an end tag, after its `<`.
Tag { end: bool },
/// An element's text.
Text,
}
/// A level of the XML literal being read.
#[derive(Clone, Copy, Debug)]
enum Level {
/// The markup of an XML literal, `open` of its elements open.
Xml { open: usize, at: At },
/// The code in braces, `braces` of them open.
Code { braces: usize },
}
/// What a `{` opened.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Brace {
/// A block, or a function declaration's body: a statement may start after its `}`.
Block,
/// An object literal: a value.
Object,
/// A function expression's body: statements inside, and a value after its `}`.
Function,
}
/// What reading a little more of an XML literal's markup led to.
enum Step {
/// The markup goes on.
On,
/// The literal ends: none of its elements is open.
Up,
/// Code in braces starts at the next character, a `{`.
Code,
/// The text ended first.
End,
}
impl<'s> Lexer<'s> {
/// A lexer of `src` that keeps at most `nesting` open parentheses, open braces, bracket levels
/// of conditionals and levels of an XML literal ([`crate::Limits::nesting`]).
pub fn new(src: &'s str, nesting: usize) -> Self {
Self {
src,
pos: 0,
line: 1,
line_start: true,
expr: true,
stmt: true,
restricted: false,
member: false,
cond: false,
parens: vec![],
deeper: 0,
braces: vec![],
deeper_braces: 0,
function: None,
questions: vec![0],
deeper_questions: 0,
xml: vec![],
xml_line: 0,
nesting,
unclosed_xml: None,
breaks: Breaks::default(),
steps: 0,
}
}
/// The lines read so far: the line breaks, and one more for a last line without one.
pub fn lines(&self) -> usize {
let last = self.src.chars().next_back();
self.line - usize::from(last.is_none_or(is_break))
}
fn rest(&self) -> &'s str {
self.src.get(self.pos..).unwrap_or_default()
}
fn peek(&self) -> Option<char> {
self.rest().chars().next()
}
fn peek_at(&self, n: usize) -> Option<char> {
self.rest().chars().nth(n)
}
fn starts(&self, s: &str) -> bool {
self.rest().starts_with(s)
}
fn bump(&mut self, c: char) {
self.pos += c.len_utf8();
self.steps += 1;
}
/// Read `n` ASCII characters.
fn skip(&mut self, n: usize) {
self.pos += n;
self.steps += 1;
}
/// A line break here, read: false when there is none.
fn line_break(&mut self) -> bool {
match self.peek() {
Some('\n') => {
self.pos += 1;
self.breaks.lf = true;
}
Some('\r') if self.starts("\r\n") => {
self.pos += 2;
self.breaks.crlf = true;
}
Some('\r') => {
self.pos += 1;
self.breaks.cr = true;
}
Some(c @ ('\u{2028}' | '\u{2029}')) => self.pos += c.len_utf8(),
_ => return false,
}
self.line += 1;
self.steps += 1;
true
}
/// Up to the next line break.
fn until_line_break(&mut self) {
while let Some(c) = self.peek().filter(|&c| !is_break(c)) {
self.bump(c);
}
}
/// The rest of a name (its first character included): letters, digits, `$`, `_` and `\u`
/// escapes.
fn name(&mut self) {
loop {
match self.peek() {
Some('\\') if self.peek_at(1) == Some('u') => {
self.skip(2);
for _ in 0..4 {
match self.peek() {
Some(h) if h.is_ascii_hexdigit() => self.bump(h),
_ => break,
}
}
}
Some(c) if ident_part(c) => self.bump(c),
_ => break,
}
}
}
fn digits(&mut self, ok: fn(char) -> bool) {
while let Some(c) = self.peek().filter(|&c| ok(c) || c == '_') {
self.bump(c);
}
}
/// A number: decimal (with a fraction and an exponent), hexadecimal, or the later binary,
/// octal and `n` (big integer) forms.
fn number(&mut self) {
let b = self.rest().as_bytes();
match (b.first(), b.get(1)) {
(Some(b'0'), Some(b'x' | b'X')) => {
self.skip(2);
self.digits(|c| c.is_ascii_hexdigit());
}
(Some(b'0'), Some(b'b' | b'B' | b'o' | b'O')) => {
self.skip(2);
self.digits(|c| c.is_ascii_digit());
}
_ => {
self.digits(|c| c.is_ascii_digit());
if self.peek() == Some('.') {
self.bump('.');
self.digits(|c| c.is_ascii_digit());
}
let signed = matches!(self.peek_at(1), Some('+' | '-')) && self.peek_at(2).is_some_and(|c| c.is_ascii_digit());
if matches!(self.peek(), Some('e' | 'E')) && (self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) || signed) {
self.skip(if signed { 2 } else { 1 });
self.digits(|c| c.is_ascii_digit());
}
}
}
if self.peek() == Some('n') {
self.bump('n');
}
}
/// A `'…'` or `"…"` string, which ends at its line's end when it isn't closed.
fn string(&mut self, quote: char) -> Option<&'static str> {
self.bump(quote);
loop {
match self.peek() {
None => return Some(UNTERMINATED_STRING),
Some(c) if c == quote => {
self.bump(c);
return None;
}
Some('\\') => {
self.bump('\\');
// A backslash before a line break carries the string on to the next line.
if !self.line_break()
&& let Some(c) = self.peek()
{
self.bump(c);
}
}
Some(c) if is_break(c) => return Some(UNTERMINATED_STRING),
Some(c) => self.bump(c),
}
}
}
fn template(&mut self) -> Option<&'static str> {
self.bump('`');
loop {
if self.line_break() {
continue;
}
match self.peek() {
None => return Some(UNTERMINATED_TEMPLATE),
Some('`') => {
self.bump('`');
return None;
}
Some('\\') => {
self.bump('\\');
if !self.line_break()
&& let Some(c) = self.peek()
{
self.bump(c);
}
}
Some(c) => self.bump(c),
}
}
}
/// A regular expression and its flags, which ends at its line's end when it isn't closed.
fn regexp(&mut self) -> Option<&'static str> {
self.bump('/');
let mut class = false;
loop {
match self.peek() {
None => return Some(UNTERMINATED_REGEXP),
Some(c) if is_break(c) => return Some(UNTERMINATED_REGEXP),
Some('\\') => {
self.bump('\\');
if let Some(c) = self.peek().filter(|&c| !is_break(c)) {
self.bump(c);
}
}
Some('/') if !class => {
self.bump('/');
break;
}
Some(c) => {
match c {
'[' => class = true,
']' => class = false,
_ => {}
}
self.bump(c);
}
}
}
while let Some(c) = self.peek().filter(|&c| ident_part(c)) {
self.bump(c);
}
None
}
fn block_comment(&mut self) -> Option<&'static str> {
self.skip(2);
loop {
if self.starts("*/") {
self.skip(2);
return None;
}
if self.line_break() {
continue;
}
match self.peek() {
Some(c) => self.bump(c),
None => return Some(UNTERMINATED_COMMENT),
}
}
}
/// Up to and past `end`: false when the text ends first.
fn past(&mut self, end: &str) -> bool {
loop {
if self.starts(end) {
self.skip(end.len());
return true;
}
if self.line_break() {
continue;
}
match self.peek() {
Some(c) => self.bump(c),
None => return false,
}
}
}
/// A token of `kind` that starts at `start` on `line`: the markup of the XML literal being
/// read, up to the literal's end or up to and with the `{` of the code in it. A `{` whose code
/// would take a level past `nesting` is read as markup, and the token has the problem
/// [`TOO_DEEP`]. When the text ends first, the literal is left unclosed
/// ([`Lexer::unclosed_xml`]). `stmt`: a statement may start at the token; `expr`: an expression
/// may start at it.
fn markup_token(&mut self, start: usize, line: usize, kind: T, stmt: bool, expr: bool) -> Token<'s> {
let mut problem = None;
while let Some(&Level::Xml { mut open, mut at }) = self.xml.last() {
let step = self.markup(&mut open, &mut at);
if let Some(level) = self.xml.last_mut() {
*level = Level::Xml { open, at };
}
match step {
Step::On => {}
Step::Code if self.xml.len() < self.nesting => {
self.bump('{');
self.xml.push(Level::Code { braces: 1 });
self.open_level();
self.expr = true;
break;
}
Step::Code => {
self.bump('{');
problem = Some(TOO_DEEP);
}
Step::Up => {
// An XML literal is a value: no expression starts after it.
self.xml.pop();
self.expr = false;
break;
}
Step::End => {
self.xml.clear();
self.unclosed_xml = Some(self.xml_line);
self.expr = false;
break;
}
}
}
// The code in an XML literal's braces is an expression, and so is the literal: no statement
// starts after either.
self.line_start = false;
self.stmt = false;
self.cond = false;
self.restricted = false;
self.member = false;
Token { kind, text: self.src.get(start..self.pos).unwrap_or_default(), line, problem, stmt, expr, start }
}
/// A little more of an XML literal's markup, `open` of its elements open and `at` where in it:
/// a comment, CDATA section or processing instruction whole, or one character of a tag or of
/// an element's text.
fn markup(&mut self, open: &mut usize, at: &mut At) -> Step {
match *at {
At::Markup => {
let read = if self.starts("<!--") {
self.past("-->")
} else if self.starts("<![CDATA[") {
self.past("]]>")
} else if self.starts("<?") {
self.past("?>")
} else {
let end = self.starts("</");
self.skip(if end { 2 } else { 1 });
*at = At::Tag { end };
return Step::On;
};
*at = At::Text;
match (read, *open) {
(false, _) => Step::End,
(true, 0) => Step::Up,
(true, _) => Step::On,
}
}
At::Tag { end } => {
if self.line_break() {
return Step::On;
}
// `>` ends a tag, and `/>` a tag whose element closes itself.
let closes_itself = match self.peek() {
None => return Step::End,
Some('>') => {
self.bump('>');
false
}
Some('/') if self.peek_at(1) == Some('>') => {
self.skip(2);
true
}
Some(q @ ('"' | '\'')) => {
self.bump(q);
return if self.past(if q == '"' { "\"" } else { "'" }) { Step::On } else { Step::End };
}
Some('{') => return Step::Code,
Some(c) => {
self.bump(c);
return Step::On;
}
};
*open = match (end, closes_itself) {
(true, _) => open.saturating_sub(1),
(false, false) => open.saturating_add(1),
(false, true) => *open,
};
*at = At::Text;
if *open == 0 { Step::Up } else { Step::On }
}
At::Text => {
if self.line_break() {
return Step::On;
}
match self.peek() {
None => Step::End,
Some('<') => {
*at = At::Markup;
Step::On
}
Some('{') => Step::Code,
Some(c) => {
self.bump(c);
Step::On
}
}
}
}
}
fn punct(&self) -> Option<&'static str> {
let rest = self.rest();
// `?.` before a digit is `?` and a number (`a ?.5 : 1`).
PUNCTS.iter().copied().find(|p| rest.starts_with(p) && !(*p == "?." && rest.as_bytes().get(2).is_some_and(u8::is_ascii_digit)))
}
/// The parentheses after the punctuator `punct`: `(` opens one, a condition when `cond` is set,
/// and `)` closes the last one open. True when `punct` closes a condition.
fn paren(&mut self, punct: &str) -> bool {
match punct {
"(" if self.parens.len() < self.nesting => self.parens.push(self.cond),
"(" => self.deeper = self.deeper.saturating_add(1),
")" if self.deeper > 0 => self.deeper -= 1,
")" => return self.parens.pop().unwrap_or(false),
_ => {}
}
false
}
/// The braces after the punctuator `punct`: `{` opens one and `}` closes the last one open. For
/// `{` and `}`, what the brace opened: the body of the function just read, a block where a
/// statement may start or no expression may, else an object literal. A brace past `nesting` is
/// taken as a block's when it closes.
fn brace(&mut self, punct: &str) -> Option<Brace> {
match punct {
"{" => {
let brace = match self.function.take() {
Some(true) => Brace::Function,
Some(false) => Brace::Block,
None if self.stmt || !self.expr => Brace::Block,
None => Brace::Object,
};
if self.braces.len() < self.nesting {
self.braces.push(brace);
} else {
self.deeper_braces = self.deeper_braces.saturating_add(1);
}
Some(brace)
}
"}" if self.deeper_braces > 0 => {
self.deeper_braces -= 1;
Some(Brace::Block)
}
"}" => Some(self.braces.pop().unwrap_or(Brace::Block)),
_ => None,
}
}
/// A bracket opens: the `?` inside it are counted apart from the ones outside.
fn open_level(&mut self) {
if self.questions.len() <= self.nesting {
self.questions.push(0);
} else {
self.deeper_questions = self.deeper_questions.saturating_add(1);
}
}
/// The innermost bracket closes.
fn close_level(&mut self) {
if self.deeper_questions > 0 {
self.deeper_questions -= 1;
} else if self.questions.len() > 1 {
self.questions.pop();
}
}
/// The punctuator `punct` at the innermost level: `?` opens a conditional there, and a `:`
/// there while one is open is that conditional's. True for that `:`.
fn conditional(&mut self, punct: &str) -> bool {
if self.deeper_questions > 0 {
return false;
}
let Some(open) = self.questions.last_mut() else { return false };
match punct {
"?" => *open = open.saturating_add(1),
":" if *open > 0 => {
*open -= 1;
return true;
}
_ => {}
}
false
}
/// A line break after the last significant token. After the end of an expression a statement
/// may start (JavaScript ends the statement at the line break when the next token can't go on
/// with it), and after `return`, `throw`, `break` and `continue` a new statement starts.
fn line_ended(&mut self) {
if std::mem::take(&mut self.restricted) {
self.expr = true;
self.stmt = true;
}
self.stmt |= !self.expr;
}
}
impl<'s> Iterator for Lexer<'s> {
type Item = Token<'s>;
fn next(&mut self) -> Option<Token<'s>> {
let (start, line, stmt, expr) = (self.pos, self.line, self.stmt, self.expr);
let Some(c) = self.peek() else {
// The text ends in the code of an XML literal.
if !self.xml.is_empty() {
self.xml.clear();
self.unclosed_xml = Some(self.xml_line);
}
return None;
};
let mut problem = None;
let kind = if self.line_break() {
T::Newline
} else if is_blank(c) {
while let Some(c) = self.peek().filter(|&c| is_blank(c)) {
self.bump(c);
}
T::Space
} else if self.starts("//") {
let directive = self.line_start && self.rest().strip_prefix("//@").is_some_and(names_directive);
self.until_line_break();
if directive { T::Directive } else { T::Comment }
} else if self.starts("/*") {
problem = self.block_comment();
T::Comment
} else if c == '#' && self.line_start {
self.until_line_break();
T::Directive
} else if c == '#' && self.peek_at(1).is_some_and(ident_start) {
self.bump('#');
self.name();
T::Other
} else if c == '\'' || c == '"' {
problem = self.string(c);
T::Str
} else if c == '`' {
problem = self.template();
T::Template
} else if c == '/' && self.expr {
problem = self.regexp();
T::RegExp
} else if c == '<' && self.expr && self.peek_at(1).is_some_and(xml_start) {
if self.xml.len() < self.nesting {
// An XML literal starts, in the script or in the code of another one.
if self.xml.is_empty() {
self.xml_line = line;
}
self.xml.push(Level::Xml { open: 0, at: At::Markup });
return Some(self.markup_token(start, line, T::Xml, stmt, expr));
}
// An XML literal more levels deep than are kept: its `<` is read as an operator.
self.skip(1);
problem = Some(TOO_DEEP);
T::Punct
} else if c == '}' && matches!(self.xml.last(), Some(Level::Code { braces: 1 })) {
// The `}` that ends the code in an XML literal's braces: the literal's markup goes on.
self.xml.pop();
self.close_level();
self.bump('}');
return Some(self.markup_token(start, line, T::XmlMore, stmt, expr));
} else if ident_start(c) || (c == '\\' && self.peek_at(1) == Some('u')) {
self.name();
T::Ident
} else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) {
self.number();
T::Number
} else if let Some(p) = self.punct() {
self.skip(p.len());
T::Punct
} else {
self.bump(c);
T::Other
};
let text = self.src.get(start..self.pos).unwrap_or_default();
match kind {
T::Space => {}
T::Newline => {
self.line_start = true;
self.line_ended();
}
// A comment that holds a line break counts as one.
T::Comment => {
self.line_start = false;
if text.contains(is_break) {
self.line_ended();
}
}
_ => {
self.line_start = false;
// A name after `.`, `..`, `.@` or `::` is a property's, also when it is a keyword: no
// expression or statement starts after it.
let property = kind == T::Ident && self.member;
// After the `)` of a condition comes a statement, which may start with a regular
// expression or an XML literal.
let condition = kind == T::Punct && self.paren(text);
// A statement may also start after the `}` of a block. After the `}` of an object
// literal or of a function expression's body, `/` divides.
let brace = if kind == T::Punct { self.brace(text) } else { None };
self.expr = condition
|| match (text, brace) {
("}", Some(brace)) => brace == Brace::Block,
_ => !property && expr_after(kind, text),
};
if kind == T::Ident && !property && text == "function" {
self.function = Some(!self.stmt);
}
if kind == T::Punct {
match text {
"(" | "[" | "{" => self.open_level(),
")" | "]" | "}" => self.close_level(),
_ => {}
}
}
let ternary = kind == T::Punct && self.conditional(text);
// A statement may start inside a block or a function's body, after `;`, `=>`, a
// preprocessor line, `else`, `do`, `try` and `finally`, and after the `:` of a label or
// a `case` (not a conditional's, and outside an object literal).
self.stmt = condition
|| match (text, brace) {
("{", Some(brace)) => brace != Brace::Object,
("}", Some(brace)) => brace == Brace::Block,
_ => false,
}
|| kind == T::Directive
|| (kind == T::Punct && matches!(text, ";" | "=>"))
|| (kind == T::Punct && text == ":" && !ternary && self.braces.last() != Some(&Brace::Object))
|| (kind == T::Ident && !property && matches!(text, "else" | "do" | "try" | "finally"));
self.cond = kind == T::Ident && !property && (matches!(text, "if" | "while" | "for" | "with") || (text == "each" && self.cond));
self.restricted = kind == T::Ident && !property && matches!(text, "return" | "throw" | "break" | "continue");
self.member = kind == T::Punct && matches!(text, "." | ".." | ".@" | "::");
// The braces inside the code of an XML literal.
if kind == T::Punct
&& let Some(Level::Code { braces }) = self.xml.last_mut()
{
match text {
"{" => *braces = braces.saturating_add(1),
"}" => *braces = braces.saturating_sub(1),
_ => {}
}
}
}
}
Some(Token { kind, text, line, problem, stmt, expr, start })
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Static checker for scripts written for the reference app's scripting: `.jsx`, `.jsxinc` and
//! `.js` files, and compiled `.jsxbin` files. [`check`] reads a script's text without running any
//! of it and reports what kind of file it is ([`Kind`]), its preprocessor directives, the platform
//! features and document-model member chains it uses, what it would do outside the open document,
//! the menu-command strings it passes, the files it includes and the problems that would stop it
//! loading.
//!
//! The lexer reads ECMAScript 3 (ECMA-262 3rd edition,
//! <https://ecma-international.org/wp-content/uploads/ECMA-262_3rd_edition_december_1999.pdf>) with
//! the XML literals and operators of E4X (ECMA-357 2nd edition,
//! <https://ecma-international.org/wp-content/uploads/ECMA-357_2nd_edition_december_2005.pdf>), and
//! it reads later syntax (template literals, `=>`) only to report it. The directive and platform
//! names come from the public pages `catalog.rs` cites by URL; only the names are used. Nothing
//! maps the reference app's names to VectorCraft commands.
//!
//! The checker never recurses: it reads each file once, and each scan of a file goes through its
//! tokens front to back, first for the names its functions and its global code declare and then
//! for the analysis. When it follows includes, it scans each file for the includes and what the
//! variables hold, again while that grows (at most three more times), and then for the report. It
//! keeps open brackets and the levels of an XML literal on capped stacks. Its work is linear in the
//! input's size, and no input can overflow the stack.
#![forbid(unsafe_code)]
mod analyze;
mod catalog;
mod directives;
mod includes;
mod lex;
mod report;
pub use includes::{Loaded, Loader};
pub use report::{Directive, Feature, Include, IncludeStatus, MenuString, Named, Outside, Problem, Report, Target};
/// The limits that keep a check small and quick; [`Limits::default`] holds the ones VectorCraft
/// uses.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Limits {
/// The most bytes of one file read: a larger script is [`Kind::TooLarge`], and a larger
/// included file isn't read.
pub file_bytes: usize,
/// The most bytes read from the script and its included files together.
pub total_bytes: usize,
/// The most included files read.
pub includes: usize,
/// How deep includes are followed: a file the script includes is at depth 1.
pub include_depth: usize,
/// The most paths looked up for included files, whether a file is found there or not.
pub lookups: usize,
/// The most open brackets tracked, and the most levels an XML literal is read to (its markup,
/// the code in a `{…}` inside it, an XML literal in that code). Deeper nesting is a problem, and
/// the check goes on.
pub nesting: usize,
/// The most entries in each list of the report, and in each table of names the check keeps (the
/// variables holding a member chain, the variables holding a File or Folder object, the names
/// one function declares, and the functions the global code declares).
pub entries: usize,
/// The most line numbers listed for one entry.
pub lines: usize,
/// The most characters of text echoed from a script (names, values, member chains).
pub echo: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
file_bytes: 8 << 20,
total_bytes: 32 << 20,
includes: 64,
include_depth: 16,
lookups: 1024,
nesting: 4096,
entries: 1000,
lines: 20,
echo: 120,
}
}
}
/// What kind of file a script is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// Text that reads as a script.
Script,
/// A compiled script (`.jsxbin`, or text that starts with `@JSXBIN@`). It isn't read.
Compiled,
/// A JavaScript module (top-level `import` or `export`), such as a React `.jsx` file: not a
/// script for the reference app.
Module,
/// Not text: a NUL byte, or more than one byte in ten a control character, in the first 8 KiB.
NotText,
/// Larger than [`Limits::file_bytes`].
TooLarge,
}
impl Kind {
pub fn id(self) -> &'static str {
match self {
Kind::Script => "script",
Kind::Compiled => "compiled",
Kind::Module => "module",
Kind::NotText => "notText",
Kind::TooLarge => "tooLarge",
}
}
}
/// A kind of help a script needs from the app that runs it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Need {
/// The script language's own objects and directives (`$`, `alert`, `UnitValue`, `#target`).
Language,
/// The reference app's document model (`app.activeDocument…`).
Dom,
/// Files and folders.
Files,
/// Menu commands run by name.
MenuCommands,
/// Dialogs and windows of its own.
Dialogs,
/// Features only the reference app has (palettes, messages to other apps, network
/// connections, native code, E4X, actions).
HostOnly,
}
impl Need {
pub const ALL: [Need; 6] = [Need::Language, Need::Dom, Need::Files, Need::MenuCommands, Need::Dialogs, Need::HostOnly];
pub fn id(self) -> &'static str {
match self {
Need::Language => "language",
Need::Dom => "dom",
Need::Files => "files",
Need::MenuCommands => "menuCommands",
Need::Dialogs => "dialogs",
Need::HostOnly => "hostOnly",
}
}
}
/// Every feature id a report can list, in the order the report lists them.
pub use catalog::FEATURES;
/// Every id in [`Report::outside`], in the order the report lists them.
pub use report::OUTSIDE;
/// Every problem code a report can list.
pub use report::PROBLEMS;
/// Check the script `bytes`, named `name` (a file name; only its last part is used). With a
/// `loader`, the files its `#include` lines name are read through it and checked too; without one
/// they are listed as [`IncludeStatus::NotRead`]. When the script or one of those files assigns a
/// member chain or a File object to a variable, the check treats the variable as holding it in
/// every file. Nothing in the script runs.
pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: &Limits) -> Report {
let mut acc = report::Acc::new(limits);
if bytes.len() > limits.file_bytes {
return acc.finish(Kind::TooLarge, 0);
}
let name = base_name(name);
if is_compiled(name, bytes) {
return acc.finish(Kind::Compiled, 0);
}
if !is_text(bytes) {
return acc.finish(Kind::NotText, 0);
}
let (text, invalid) = decode(bytes);
if invalid {
acc.problem(report::NOT_UTF8, None, None, None);
}
let none = analyze::Known::default();
// With a loader, this first scan finds the includes and what the script's variables hold; its
// tallies are left out.
let mut tallies = report::Acc::new(limits);
let scan = analyze::scan(&text, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc });
acc.steps = acc.steps.saturating_add(tallies.steps);
if scan.module {
// A module's report holds its kind and its lines, and none of what the scan found.
let steps = acc.steps;
let mut acc = report::Acc::new(limits);
acc.steps = steps;
return acc.finish(Kind::Module, scan.lines);
}
let Some(loader) = loader else {
includes::not_read(&mut acc, scan.includes);
return acc.finish(Kind::Script, scan.lines);
};
let mut known = scan.known;
let read = includes::follow(&mut acc, scan.includes, loader, name, bytes.len(), &mut known);
// A variable may get what it holds from another file's variable: the files are scanned again,
// each with what is known so far, while that grows, at most three more times.
for _ in 0..3 {
let before = known.len();
let mut tallies = report::Acc::new(limits);
let more = analyze::scan(&text, None, std::rc::Rc::from([]), &known, &mut tallies).known;
known.add(more, limits.entries);
for file in &read {
let more = analyze::scan(&file.text, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known;
known.add(more, limits.entries);
}
acc.steps = acc.steps.saturating_add(tallies.steps);
if known.len() == before {
break;
}
}
// The scans for the report: every file starts with what any file's variables hold.
let lines = analyze::scan(&text, None, std::rc::Rc::from([]), &known, &mut acc).lines;
for file in read {
if file.invalid {
acc.problem(report::NOT_UTF8, None, Some(&file.path), None);
}
analyze::scan(&file.text, Some(&file.path), file.paths, &known, &mut acc);
}
acc.finish(Kind::Script, lines)
}
/// The last part of a path (`lib/Tidy.jsx` → `Tidy.jsx`), whichever separator it uses.
pub(crate) fn base_name(name: &str) -> &str {
name.rsplit(['/', '\\']).next().unwrap_or(name)
}
/// A compiled script: named `.jsxbin`, or text that starts with `@JSXBIN@` (after a byte order mark
/// and blanks).
pub(crate) fn is_compiled(name: &str, bytes: &[u8]) -> bool {
if base_name(name).rsplit_once('.').is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("jsxbin")) {
return true;
}
let b = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
let start = b.iter().position(|c| !c.is_ascii_whitespace()).unwrap_or(b.len());
b.get(start..).is_some_and(|b| b.starts_with(b"@JSXBIN@"))
}
/// Text, judged by its first 8 KiB: no NUL byte, and at most one byte in ten a control character
/// other than tab, line feed, vertical tab, form feed, carriage return and escape.
pub(crate) fn is_text(bytes: &[u8]) -> bool {
let head = bytes.get(..bytes.len().min(8 << 10)).unwrap_or(bytes);
if head.contains(&0) {
return false;
}
let controls = head.iter().filter(|&&b| b < 0x20 && !matches!(b, b'\t' | b'\n' | b'\r' | 0x0b | 0x0c | 0x1b)).count();
controls * 10 <= head.len()
}
/// The text of `bytes`: UTF-8 after an optional byte order mark, with invalid bytes replaced by
/// U+FFFD (`true` when there were any).
pub(crate) fn decode(bytes: &[u8]) -> (std::borrow::Cow<'_, str>, bool) {
let b = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
let text = String::from_utf8_lossy(b);
let invalid = matches!(text, std::borrow::Cow::Owned(_));
(text, invalid)
}
#[cfg(test)]
mod tests;
+417
View File
@@ -0,0 +1,417 @@
//! What a check finds ([`Report`]), and the tallies it is built from ([`Acc`]), each list capped.
use std::collections::{BTreeMap, BTreeSet};
use crate::{Kind, Limits, Need, catalog};
pub(crate) const UNTERMINATED_STRING: &str = "unterminatedString";
pub(crate) const UNTERMINATED_COMMENT: &str = "unterminatedComment";
pub(crate) const UNTERMINATED_REGEXP: &str = "unterminatedRegExp";
pub(crate) const UNTERMINATED_TEMPLATE: &str = "unterminatedTemplate";
pub(crate) const UNTERMINATED_XML: &str = "unterminatedXml";
pub(crate) const UNCLOSED_BRACKET: &str = "unclosedBracket";
pub(crate) const UNEXPECTED_BRACKET: &str = "unexpectedBracket";
pub(crate) const NEWER_SYNTAX: &str = "newerSyntax";
pub(crate) const UNKNOWN_DIRECTIVE: &str = "unknownDirective";
pub(crate) const NOT_UTF8: &str = "notUtf8";
pub(crate) const MIXED_LINE_ENDINGS: &str = "mixedLineEndings";
pub(crate) const TOO_DEEP: &str = "tooDeep";
/// Every problem code a report can list.
pub const PROBLEMS: &[&str] = &[
UNTERMINATED_STRING,
UNTERMINATED_COMMENT,
UNTERMINATED_REGEXP,
UNTERMINATED_TEMPLATE,
UNTERMINATED_XML,
UNCLOSED_BRACKET,
UNEXPECTED_BRACKET,
NEWER_SYNTAX,
UNKNOWN_DIRECTIVE,
NOT_UTF8,
MIXED_LINE_ENDINGS,
TOO_DEEP,
];
/// Every id in [`Report::outside`], in the order the report lists them: files written, moved or
/// deleted; files opened in other apps; network connections; native code; messages to other apps;
/// environment variables; code the checker can't read; the reference app's preferences changed.
pub const OUTSIDE: &[&str] = &["filesWritten", "filesOpened", "network", "nativeCode", "otherApps", "environment", "unseenCode", "preferences"];
/// What [`crate::check`] found in a script. Lists of located things (targets, directives,
/// includes, problems) give a line and, for an included file, its path (`file`); tallies (features,
/// menu strings, outside effects) count the script and its included files together and list the
/// script's own lines.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Report {
pub kind: Kind,
/// What the script needs from the app that runs it, in [`Need`]'s order.
pub needs: Vec<Need>,
/// The script's lines.
pub lines: usize,
/// Each `#target` line's application specifier.
pub targets: Vec<Target>,
pub directives: Vec<Directive>,
pub includes: Vec<Include>,
/// The platform features used, by id ([`crate::FEATURES`]).
pub features: Vec<Feature>,
/// The document-model member chains used, as written (`app.activeDocument.layers[].name`, with
/// `[]` for an index and `()` for a call), a variable assigned from a chain replaced by that
/// chain, and how often each occurs; the most used first.
pub members: Vec<Named>,
/// The type names compared with `.typename` or created with `new`, apart from the language's,
/// the platform's and the script's own functions.
pub types: Vec<Named>,
/// The menu-command strings passed as string literals; the most used first.
pub menu_strings: Vec<MenuString>,
/// Menu-command calls whose argument isn't a string literal.
pub menu_dynamic: usize,
/// What the script does outside the document, in [`OUTSIDE`]'s order.
pub outside: Vec<Outside>,
pub problems: Vec<Problem>,
/// A list of the report, or a table of names the check keeps, reached [`Limits::entries`], and
/// entries were left out.
pub truncated: bool,
/// The steps the check took: the tests check that they grow linearly with the input.
pub(crate) steps: usize,
}
/// A `#target` line's application specifier, split into its parts.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Target {
pub value: String,
pub app: String,
pub instance: Option<String>,
pub version: Option<String>,
pub locale: Option<String>,
pub line: usize,
pub file: Option<String>,
}
/// A preprocessor line of a documented directive.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Directive {
/// The directive (`target`, `include`…).
pub name: &'static str,
pub value: String,
pub line: usize,
/// `#` or `//@`.
pub form: &'static str,
pub file: Option<String>,
}
/// What became of an `#include` line.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IncludeStatus {
/// Read and checked (or checked already, for a file included twice).
Checked,
/// No such file in the script's folder, or in the folders `#includepath` names inside it.
Missing,
/// Not followed: an absolute name, a name longer than 4,096 bytes, a name that leads out of the
/// script's folder, or a file the caller may not read.
NotFollowed,
/// The file it names includes the file the line is in, directly or through other files.
Cycle,
/// Includes weren't read (no loader).
NotRead,
/// Not read: a limit was reached (files, depth, size, paths looked up).
Limit,
/// A compiled file, or not text.
Unreadable,
}
impl IncludeStatus {
pub const ALL: [IncludeStatus; 7] = [
IncludeStatus::Checked,
IncludeStatus::Missing,
IncludeStatus::NotFollowed,
IncludeStatus::Cycle,
IncludeStatus::NotRead,
IncludeStatus::Limit,
IncludeStatus::Unreadable,
];
pub fn id(self) -> &'static str {
match self {
IncludeStatus::Checked => "checked",
IncludeStatus::Missing => "missing",
IncludeStatus::NotFollowed => "notFollowed",
IncludeStatus::Cycle => "cycle",
IncludeStatus::NotRead => "notRead",
IncludeStatus::Limit => "limit",
IncludeStatus::Unreadable => "unreadable",
}
}
}
/// An `#include` line and what became of it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Include {
/// The name as the line writes it.
pub value: String,
pub line: usize,
/// The included file the line is in.
pub file: Option<String>,
pub status: IncludeStatus,
/// The file it names, as a path inside the script's folder, when one was found.
pub path: Option<String>,
}
/// A platform feature: how often it occurs, and the script's first lines with it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Feature {
pub id: &'static str,
pub count: usize,
pub lines: Vec<usize>,
}
/// A name and how often it occurs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Named {
pub name: String,
pub count: usize,
}
/// A menu-command string: how often it is passed, and the script's first lines with it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MenuString {
pub value: String,
pub count: usize,
pub lines: Vec<usize>,
}
/// Something the script does outside the document ([`OUTSIDE`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Outside {
pub id: &'static str,
pub count: usize,
pub lines: Vec<usize>,
/// For `filesWritten`: the open modes used (`w`, `a`, `e`).
pub modes: Vec<&'static str>,
}
/// A problem that would stop the script loading or running as written.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Problem {
pub code: &'static str,
pub line: Option<usize>,
pub file: Option<String>,
/// The text concerned, as written (an unknown directive, a construct of later syntax, a
/// bracket).
pub detail: Option<String>,
}
/// A count and the first lines it was seen on.
#[derive(Clone, Debug, Default)]
struct Tally {
count: usize,
lines: Vec<usize>,
}
impl Tally {
fn add(&mut self, line: Option<usize>, max: usize) {
self.count = self.count.saturating_add(1);
if let Some(line) = line
&& self.lines.len() < max
&& self.lines.last() != Some(&line)
{
self.lines.push(line);
}
}
}
/// What a check has found so far, across the script and its included files.
pub(crate) struct Acc<'l> {
pub limits: &'l Limits,
pub steps: usize,
features: BTreeMap<&'static str, Tally>,
outside: BTreeMap<&'static str, (Tally, Vec<&'static str>)>,
menu: BTreeMap<String, Tally>,
menu_dynamic: usize,
members: BTreeMap<String, usize>,
types: BTreeMap<String, usize>,
/// Functions the script declares: `new` of one is no document-model type.
declared: BTreeSet<String>,
targets: Vec<Target>,
directives: Vec<Directive>,
includes: Vec<Include>,
problems: Vec<Problem>,
truncated: bool,
}
impl<'l> Acc<'l> {
pub fn new(limits: &'l Limits) -> Self {
Self {
limits,
steps: 0,
features: BTreeMap::new(),
outside: BTreeMap::new(),
menu: BTreeMap::new(),
menu_dynamic: 0,
members: BTreeMap::new(),
types: BTreeMap::new(),
declared: BTreeSet::new(),
targets: vec![],
directives: vec![],
includes: vec![],
problems: vec![],
truncated: false,
}
}
/// `s` as the report echoes it: at most [`Limits::echo`] characters, the last one `…` when
/// it was cut.
pub fn echo(&self, s: &str) -> String {
echo(s, self.limits.echo)
}
/// Room for one more entry in a list of the report, or in a table of names the check keeps, of
/// `len` entries: false (and the report marked truncated) when it is full.
pub fn room(&mut self, len: usize) -> bool {
let room = len < self.limits.entries;
self.truncated |= !room;
room
}
pub fn feature(&mut self, id: &'static str, line: Option<usize>) {
let max = self.limits.lines;
self.features.entry(id).or_default().add(line, max);
}
pub fn outside(&mut self, id: &'static str, line: Option<usize>, mode: Option<&'static str>) {
let max = self.limits.lines;
let (tally, modes) = self.outside.entry(id).or_default();
tally.add(line, max);
if let Some(m) = mode
&& !modes.contains(&m)
{
modes.push(m);
}
}
pub fn menu_string(&mut self, value: &str, line: Option<usize>) {
let key = self.echo(value);
if !self.menu.contains_key(&key) && !self.room(self.menu.len()) {
return;
}
let max = self.limits.lines;
self.menu.entry(key).or_default().add(line, max);
}
pub fn menu_dynamic(&mut self) {
self.menu_dynamic = self.menu_dynamic.saturating_add(1);
}
pub fn member(&mut self, path: &str) {
if !self.members.contains_key(path) && !self.room(self.members.len()) {
return;
}
let n = self.members.entry(path.to_string()).or_default();
*n = n.saturating_add(1);
}
pub fn type_name(&mut self, name: &str) {
let key = self.echo(name);
if !self.types.contains_key(&key) && !self.room(self.types.len()) {
return;
}
let n = self.types.entry(key).or_default();
*n = n.saturating_add(1);
}
pub fn declared(&mut self, name: &str) {
if self.declared.len() < self.limits.entries {
self.declared.insert(self.echo(name));
}
}
pub fn problem(&mut self, code: &'static str, line: Option<usize>, file: Option<&str>, detail: Option<&str>) {
if self.room(self.problems.len()) {
let detail = detail.map(|d| self.echo(d));
self.problems.push(Problem { code, line, file: file.map(str::to_string), detail });
}
}
pub fn directive(&mut self, name: &'static str, value: &str, line: usize, form: &'static str, file: Option<&str>) {
if self.room(self.directives.len()) {
let value = self.echo(value);
self.directives.push(Directive { name, value, line, form, file: file.map(str::to_string) });
}
}
pub fn target(&mut self, value: &str, line: usize, file: Option<&str>) {
if !self.room(self.targets.len()) {
return;
}
let t = crate::directives::target(value);
let part = |s: Option<&str>| s.map(|s| echo(s, self.limits.echo));
let target = Target {
value: self.echo(value),
app: self.echo(t.app),
instance: part(t.instance),
version: part(t.version),
locale: part(t.locale),
line,
file: file.map(str::to_string),
};
self.targets.push(target);
}
pub fn include(&mut self, value: &str, line: usize, file: Option<&str>, status: IncludeStatus, path: Option<&str>) {
if self.room(self.includes.len()) {
let include = Include { value: self.echo(value), line, file: file.map(str::to_string), status, path: path.map(|p| self.echo(p)) };
self.includes.push(include);
}
}
/// The report for a file of `kind` with `lines` lines.
pub fn finish(self, kind: Kind, lines: usize) -> Report {
let declared = self.declared;
let mut members: Vec<Named> = self.members.into_iter().map(|(name, count)| Named { name, count }).collect();
members.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
let mut types: Vec<Named> =
self.types.into_iter().filter(|(name, _)| !declared.contains(name)).map(|(name, count)| Named { name, count }).collect();
types.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
let mut menu_strings: Vec<MenuString> =
self.menu.into_iter().map(|(value, t)| MenuString { value, count: t.count, lines: t.lines }).collect();
menu_strings.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
let features: Vec<Feature> = self.features.into_iter().map(|(id, t)| Feature { id, count: t.count, lines: t.lines }).collect();
let mut outside: Vec<Outside> =
self.outside.into_iter().map(|(id, (t, modes))| Outside { id, count: t.count, lines: t.lines, modes }).collect();
outside.sort_by_key(|o| OUTSIDE.iter().position(|id| *id == o.id));
let mut needs: BTreeSet<Need> = features.iter().filter_map(|f| catalog::need(f.id)).collect();
if !members.is_empty() || !types.is_empty() {
needs.insert(Need::Dom);
}
Report {
kind,
needs: needs.into_iter().collect(),
lines,
targets: self.targets,
directives: self.directives,
includes: self.includes,
features,
members,
types,
menu_strings,
menu_dynamic: self.menu_dynamic,
outside,
problems: self.problems,
truncated: self.truncated,
steps: self.steps,
}
}
}
/// `s` cut to at most `max` characters, the last one `…` when it was cut.
pub(crate) fn echo(s: &str, max: usize) -> String {
match s.char_indices().nth(max) {
None => s.to_string(),
Some(_) => {
let keep = s.char_indices().nth(max.saturating_sub(1)).map_or(0, |(i, _)| i);
let mut out = s.get(..keep).unwrap_or_default().to_string();
out.push('…');
out
}
}
}
File diff suppressed because it is too large. Load diff
+10 -7
View File
@@ -50,6 +50,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
body: String::new(),
defs: String::new(),
classes: Vec::new(),
class_ids: HashMap::new(),
used_ids: HashSet::new(),
names: HashMap::new(),
depth: 1,
@@ -259,8 +260,10 @@ struct Writer<'a> {
xf: Affine,
body: String,
defs: String,
/// Unique CSS declaration blocks; class name is `cls-{index + 1}`.
/// Unique CSS declaration blocks, in first-use order (used for deterministic output).
classes: Vec<String>,
/// 1-based class/entity number by declaration; avoids a linear scan for each style.
class_ids: HashMap<String, usize>,
used_ids: HashSet<String>,
names: HashMap<NodeId, String>,
depth: usize,
@@ -657,13 +660,13 @@ impl Writer<'_> {
/// The 1-based number of a distinct declaration block (a CSS class or a style entity).
fn class(&mut self, decl: String) -> usize {
match self.classes.iter().position(|c| *c == decl) {
Some(i) => i + 1,
None => {
self.classes.push(decl);
self.classes.len()
}
if let Some(&i) = self.class_ids.get(decl.as_str()) {
return i;
}
let i = self.classes.len() + 1;
self.class_ids.insert(decl.clone(), i);
self.classes.push(decl);
i
}
/// `<metadata>`: the Dublin Core terms of File Info ([`ExportOptions::metadata`]) and the
+99 -3
View File
@@ -21,9 +21,11 @@ use text::TextSlots;
pub(crate) use fx::BLEND;
mod css;
mod css_transform;
mod files;
mod fx;
mod spread;
mod symbol_units;
mod text;
mod vector_effect;
@@ -81,6 +83,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
base *= aff(g.transform());
top = g;
}
if let Some(boards) = inkscape_pages(&xml, base) {
im.doc.artboards = boards;
}
// Top-level `<g id>` elements become layers (as in the reference app), named from their
// labels (`data-name`…) else their ids; otherwise all art goes into "Layer 1". A clip path on
@@ -243,6 +248,70 @@ impl RootUnits {
}
}
/// `'id' (feA, feB)` for each filter: its id and the primitives it uses, for warnings.
fn filter_names(filters: &[Arc<usvg::filter::Filter>]) -> String {
use usvg::filter::Kind as K;
let names: Vec<String> = filters
.iter()
.map(|f| {
let mut kinds: Vec<&str> = Vec::new();
for p in f.primitives() {
let k = match p.kind() {
K::Blend(_) => "feBlend",
K::ColorMatrix(_) => "feColorMatrix",
K::ComponentTransfer(_) => "feComponentTransfer",
K::Composite(_) => "feComposite",
K::ConvolveMatrix(_) => "feConvolveMatrix",
K::DiffuseLighting(_) => "feDiffuseLighting",
K::DisplacementMap(_) => "feDisplacementMap",
K::DropShadow(_) => "feDropShadow",
K::Flood(_) => "feFlood",
K::GaussianBlur(_) => "feGaussianBlur",
K::Image(_) => "feImage",
K::Merge(_) => "feMerge",
K::Morphology(_) => "feMorphology",
K::Offset(_) => "feOffset",
K::SpecularLighting(_) => "feSpecularLighting",
K::Tile(_) => "feTile",
K::Turbulence(_) => "feTurbulence",
};
if !kinds.contains(&k) {
kinds.push(k);
}
}
let id = if f.id().is_empty() || made_up(f.id()) { String::new() } else { format!("'{}' ", f.id()) };
format!("{id}({})", kinds.join(", "))
})
.collect();
names.join(", ")
}
/// Inkscape's pages (`<inkscape:page>` in its `<sodipodi:namedview>`, in user units) as artboards
/// through `user` (user units → document points), named by their labels; `None` without any.
fn inkscape_pages(xml: &roxmltree::Document, user: Affine) -> Option<Vec<vectorcraft_doc::Artboard>> {
let num = |n: roxmltree::Node, a: &str| n.attribute(a).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
let pages: Vec<vectorcraft_doc::Artboard> = xml
.descendants()
.filter(|n| n.is_element() && n.tag_name().name() == "page" && n.tag_name().namespace() == Some(INKSCAPE))
.filter_map(|p| {
let (w, h) = (num(p, "width")?, num(p, "height")?);
let (x, y) = (num(p, "x").unwrap_or(0.0), num(p, "y").unwrap_or(0.0));
// A box too large to add up or to place (1e308 wide) is no page.
let r = user.transform_rect_bbox(Rect::new(x, y, x + w, y + h));
(w > 0.0 && h > 0.0 && r.is_finite()).then_some((p, r))
})
.take(1000)
.enumerate()
.map(|(i, (p, r))| vectorcraft_doc::Artboard {
id: i as u32 + 1,
name: p.attribute((INKSCAPE, "label")).filter(|l| !l.trim().is_empty()).map_or_else(|| format!("Artboard {}", i + 1), str::to_string),
rect: r,
show_center_mark: false,
show_cross_hairs: false,
})
.collect();
(!pages.is_empty()).then_some(pages)
}
/// How many clip paths deep (`<clipPath clip-path>`) an object's clip is read.
const MAX_CLIP_NEST: usize = 16;
@@ -464,11 +533,15 @@ impl Edits {
/// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded,
/// to come back hidden; one something links to (a `<use>` template) stays as it is;
/// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]);
/// * linked files are read ([`files`]).
/// * linked files are read ([`files`]);
/// * percentage lengths in a `<symbol>` with a `viewBox` become numbers ([`symbol_units`]).
fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
let mut found = Found::default();
// (`:` for prefixed elements such as `<svg:image>`.)
if !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t)) {
let css_transform = has_css_transform(svg);
if !css_transform
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t))
{
return (svg.into(), found);
}
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
@@ -507,9 +580,20 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
}
}
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));
}
if css_transform {
edits.list.extend(css_transform::rewrites(svg, &xml));
}
(edits.apply(svg), found)
}
/// Is there a `transform` CSS declaration in `svg` (`transform` followed by `:`)?
fn has_css_transform(svg: &str) -> bool {
svg.match_indices("transform").any(|(i, t)| svg.get(i + t.len()..).is_some_and(|s| s.trim_start().starts_with(':')))
}
/// The elements that are objects (what [`hidden_objects`] shows).
const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"];
@@ -885,7 +969,19 @@ impl Importer {
let effects = match g.filters() {
[] => vec![],
filters => fx::effects(filters, ts, &self.blends).unwrap_or_else(|| {
self.warn(format!("filter on {label} ignored"));
// usvg wraps a filtered element in a group of its own: name the element then.
let on = match g.children() {
[usvg::Node::Group(_)] | [] => label.clone(),
[one] if made_up(g.id()) || g.id().is_empty() => {
if one.id().is_empty() || made_up(one.id()) {
"an object".to_string()
} else {
format!("'{}'", one.id())
}
}
_ => label.clone(),
};
self.warn(format!("filter {} on {on} isn't supported; drawn without it", filter_names(filters)));
vec![]
}),
};
+34 -1
View File
@@ -15,7 +15,8 @@ use usvg::roxmltree;
pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag.
/// `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)> {
s.split(';')
.filter_map(|d| {
@@ -27,9 +28,28 @@ fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
};
Some((k.trim().to_ascii_lowercase(), v.to_string(), important))
})
.flat_map(|(k, v, important)| match k.as_str() {
"font" => font_longhands(&v).into_iter().map(|(k, v)| (k.to_string(), v, important)).collect(),
_ => vec![(k, v, important)],
})
.collect()
}
/// The longhands of a `font` shorthand: those it leaves out back to their initial values. None
/// when it doesn't parse (it is then ignored, as in CSS).
fn font_longhands(v: &str) -> Vec<(&'static str, String)> {
let Ok(f) = svgtypes::FontShorthand::from_str(v) else { return Vec::new() };
let or = |o: Option<&str>| o.unwrap_or("normal").to_string();
vec![
("font-style", or(f.font_style)),
("font-variant", or(f.font_variant)),
("font-weight", or(f.font_weight)),
("font-stretch", or(f.font_stretch)),
("font-size", f.font_size.to_string()),
("font-family", f.font_family.to_string()),
]
}
/// One compound selector (`text.a.b#c[x=y]`).
#[derive(Debug, Default)]
struct Compound {
@@ -343,6 +363,19 @@ mod tests {
);
}
#[test]
fn the_font_shorthand_sets_its_longhands() {
let d = parse_decls("fill: red; font: italic bold 30px 'DejaVu Sans' ; font-weight: 300 !important; font: nonsense");
let get = |k: &str| d.iter().filter(|(n, ..)| n == k).map(|(_, v, i)| (v.as_str(), *i)).collect::<Vec<_>>();
assert_eq!(get("font-style"), [("italic", false)]);
assert_eq!(get("font-weight"), [("bold", false), ("300", true)]);
assert_eq!(get("font-size"), [("30px", false)]);
assert_eq!(get("font-family"), [("'DejaVu Sans'", false)]);
assert_eq!(get("font-variant"), [("normal", false)]);
assert_eq!(get("fill"), [("red", false)]);
assert!(get("font").is_empty(), "the shorthand itself is not kept");
}
#[test]
fn unsupported_selectors_are_dropped() {
for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", ""] {
+151
View File
@@ -0,0 +1,151 @@
//! CSS `transform` declarations in SVG syntax, for usvg.
//!
//! usvg takes the `transform` property (from a `style` attribute or a `<style>` rule) as a
//! presentation attribute that overrides the element's `transform` attribute, but reads its value
//! as SVG, without CSS units: `translate(100px, 0)` or `rotate(45deg)` fails, and the element is
//! left with no transform at all (#1557). Before usvg runs, such values are rewritten in SVG
//! syntax: lengths in `px` (or unitless), angles in `deg`, `rad`, `grad` or `turn`, and the CSS
//! functions SVG lacks (`translateX`, `scaleY`, `skew`…) spelled with SVG's. A value that can't be
//! (percentages, `em`, 3D functions) is left as it is.
use std::f64::consts::PI;
use std::ops::Range;
use usvg::roxmltree;
use super::css::XNode;
/// The CSS transform `value` in SVG syntax; `None` when it has nothing to rewrite or can't be.
pub(super) fn to_svg(value: &str) -> Option<String> {
let v = value.trim();
if v.eq_ignore_ascii_case("none") {
return Some("matrix(1 0 0 1 0 0)".into());
}
let mut out = Vec::new();
let mut rest = v;
while !rest.trim_start().is_empty() {
rest = rest.trim_start();
let open = rest.find('(')?;
let close = rest.find(')')?;
let name = rest.get(..open)?.trim().to_ascii_lowercase();
let args: Vec<&str> = rest.get(open + 1..close)?.split(|c: char| c == ',' || c.is_whitespace()).filter(|a| !a.is_empty()).collect();
let len = |i: usize| args.get(i).and_then(|a| length(a));
let ang = |i: usize| args.get(i).and_then(|a| angle(a));
let num = |i: usize| args.get(i).and_then(|a| a.parse::<f64>().ok()).filter(|n| n.is_finite());
let f = match (name.as_str(), args.len()) {
("matrix", 6) => format!("matrix({} {} {} {} {} {})", num(0)?, num(1)?, num(2)?, num(3)?, len(4)?, len(5)?),
("translate", 1) => format!("translate({} 0)", len(0)?),
("translate", 2) => format!("translate({} {})", len(0)?, len(1)?),
("translatex", 1) => format!("translate({} 0)", len(0)?),
("translatey", 1) => format!("translate(0 {})", len(0)?),
("scale", 1) => format!("scale({})", num(0)?),
("scale", 2) => format!("scale({} {})", num(0)?, num(1)?),
("scalex", 1) => format!("scale({} 1)", num(0)?),
("scaley", 1) => format!("scale(1 {})", num(0)?),
("rotate", 1) => format!("rotate({})", ang(0)?),
("skewx", 1) => format!("skewX({})", ang(0)?),
("skewy", 1) => format!("skewY({})", ang(0)?),
("skew", 1) => format!("skewX({})", ang(0)?),
("skew", 2) => format!("matrix(1 {} {} 1 0 0)", ang(1)?.to_radians().tan(), ang(0)?.to_radians().tan()),
_ => return None,
};
out.push(f);
rest = rest.get(close + 1..)?;
}
let svg = out.join(" ");
(!out.is_empty() && svg != v).then_some(svg)
}
/// A length in user units: `px` or unitless.
fn length(a: &str) -> Option<f64> {
let n = a.strip_suffix("px").unwrap_or(a).parse::<f64>().ok()?;
n.is_finite().then_some(n)
}
/// An angle in degrees.
fn angle(a: &str) -> Option<f64> {
let a = a.to_ascii_lowercase();
let (n, k) = if let Some(n) = a.strip_suffix("deg") {
(n, 1.0)
} else if let Some(n) = a.strip_suffix("grad") {
(n, 0.9)
} else if let Some(n) = a.strip_suffix("rad") {
(n, 180.0 / PI)
} else if let Some(n) = a.strip_suffix("turn") {
(n, 360.0)
} else {
(a.as_str(), 1.0)
};
let d = n.parse::<f64>().ok()? * k;
d.is_finite().then_some(d)
}
/// Where CSS can be in `svg`: `style` attribute values and `<style>` contents.
fn css_ranges(xml: &roxmltree::Document) -> Vec<Range<usize>> {
let mut out = Vec::new();
for n in xml.descendants().filter(XNode::is_element) {
if let Some(a) = n.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) {
out.push(a.range_value());
}
if n.tag_name().name() == "style" {
out.extend(n.children().filter(XNode::is_text).map(|t| t.range()));
}
}
out
}
/// The `transform` declarations in CSS in `svg`, each as (value range, value in SVG syntax).
pub(super) fn rewrites(svg: &str, xml: &roxmltree::Document) -> Vec<(Range<usize>, String)> {
let mut out = Vec::new();
for r in css_ranges(xml) {
let Some(css) = svg.get(r.clone()) else { continue };
let mut from = 0;
while let Some(i) = css.get(from..).and_then(|s| s.find("transform")).map(|i| i + from) {
from = i + "transform".len();
let before = css.get(..i).and_then(|s| s.chars().next_back());
if before.is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
continue;
}
let after = css.get(from..).unwrap_or("");
let Some(colon) = after.find(|c: char| !c.is_whitespace()).filter(|&k| after.get(k..).is_some_and(|s| s.starts_with(':'))) else {
continue;
};
let start = from + colon + 1;
let len = css.get(start..).and_then(|s| s.find([';', '}', '!', '"', '\'', '<'])).unwrap_or(css.len() - start);
let Some(value) = css.get(start..start + len) else { continue };
if let Some(svg_value) = to_svg(value) {
out.push((r.start + start..r.start + start + len, format!(" {svg_value}")));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn css_transforms_become_svg() {
assert_eq!(to_svg("translate(100px, 100px)").as_deref(), Some("translate(100 100)"));
assert_eq!(to_svg(" translateX(5px) rotate(0.25turn) ").as_deref(), Some("translate(5 0) rotate(90)"));
assert_eq!(to_svg("rotate(1.5707963267948966rad) scaleY(2)").as_deref(), Some("rotate(90) scale(1 2)"));
assert_eq!(to_svg("skew(45deg)").as_deref(), Some("skewX(45)"));
assert_eq!(to_svg("matrix(1, 0, 0, 1, 10px, 20px)").as_deref(), Some("matrix(1 0 0 1 10 20)"));
assert_eq!(to_svg("none").as_deref(), Some("matrix(1 0 0 1 0 0)"));
// Already SVG, or not expressible: left alone.
assert_eq!(to_svg("translate(10 20)"), None);
assert_eq!(to_svg("translate(50%, 0)"), None);
assert_eq!(to_svg("rotate3d(1, 0, 0, 45deg)"), None);
assert_eq!(to_svg("translate(1px"), None);
}
#[test]
fn only_css_is_rewritten() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><style>.a { fill: red; transform: rotate(45deg) } .b{-webkit-transform:rotate(1deg)}</style><rect class="a" transform="translate(1 2)" style="transform:translate(3px,4px);fill:blue"/><text>transform: rotate(9deg)</text></svg>"#;
let xml = roxmltree::Document::parse(svg).unwrap();
let found = rewrites(svg, &xml);
let r: Vec<(&str, &str)> = found.iter().map(|(r, v)| (&svg[r.clone()], v.as_str())).collect();
assert_eq!(r, vec![(" rotate(45deg) ", " rotate(45)"), ("translate(3px,4px)", " translate(3 4)")]);
}
}
+72
View File
@@ -0,0 +1,72 @@
//! Percentage lengths inside a `<symbol>` with a `viewBox`, as numbers, for usvg.
//!
//! A percentage is of the nearest viewport's size in user units, and a symbol's `viewBox` sets
//! that size for its content as a nested `<svg>`'s does (SVG 2, 8.9 Units; 5.6 The `use` element).
//! usvg takes the percentages of the `<use>`'s width and height instead and then scales the art by
//! the `viewBox`, so it comes out too large and out of place (#1561). Before usvg runs, the
//! percentages of the shapes' geometry are rewritten in the `viewBox`'s units.
use std::ops::Range;
use std::str::FromStr;
use usvg::roxmltree;
/// The elements whose geometry attributes are rewritten.
const SHAPES: &[&str] = &["rect", "circle", "ellipse", "line", "image", "use", "text"];
/// The elements whose content has lengths of its own kind (or a viewport of its own).
const SCOPES: &[&str] = &["symbol", "svg", "pattern", "mask", "clipPath", "marker"];
/// What a percentage of attribute `name` is of, in a viewport of `w` × `h`: its width, its height,
/// or (a radius) its diagonal over √2.
fn basis(name: &str, w: f64, h: f64) -> Option<f64> {
match name {
"x" | "x1" | "x2" | "cx" | "width" | "rx" => Some(w),
"y" | "y1" | "y2" | "cy" | "height" | "ry" => Some(h),
"r" => Some(((w * w + h * h) / 2.0).sqrt()),
_ => None,
}
}
/// The percentage geometry of shapes in symbols with a `viewBox`, each as (value range, number).
pub(super) fn rewrites(xml: &roxmltree::Document) -> Vec<(Range<usize>, String)> {
let mut out = Vec::new();
for n in xml.descendants().filter(|n| n.is_element() && SHAPES.contains(&n.tag_name().name())) {
let Some(scope) = n.ancestors().skip(1).find(|a| SCOPES.contains(&a.tag_name().name())) else { continue };
let Some(vb) = Some(scope)
.filter(|s| s.tag_name().name() == "symbol")
.and_then(|s| s.attribute("viewBox"))
.and_then(|v| svgtypes::ViewBox::from_str(v).ok())
else {
continue;
};
if !(vb.w > 0.0 && vb.h > 0.0 && vb.w.is_finite() && vb.h.is_finite()) {
continue;
}
for a in n.attributes().filter(|a| a.namespace().is_none()) {
let percent = a.value().trim().strip_suffix('%').and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
if let (Some(p), Some(of)) = (percent, basis(a.name(), vb.w, vb.h)) {
out.push((a.range_value(), (p / 100.0 * of).to_string()));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percentages_in_a_symbol_are_of_its_view_box() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg"><rect width="50%"/>
<symbol id="a" viewBox="0 0 200 100"><rect x="10%" y="10%" width="100%" height="50%" rx="5"/><circle cx="50%" r="100%"/>
<linearGradient x2="100%"/><pattern><rect width="100%"/></pattern><g><line x2="25%" y2=" 25% "/></g></symbol>
<symbol id="b"><rect width="100%"/></symbol><use href="#a" width="30%"/></svg>"##;
let xml = roxmltree::Document::parse(svg).unwrap();
let found: Vec<(&str, String)> = rewrites(&xml).into_iter().map(|(r, v)| (&svg[r], v)).collect();
let r = (200.0f64 * 200.0 / 2.0 + 100.0 * 100.0 / 2.0).sqrt().to_string();
let expected =
[("10%", "20"), ("10%", "10"), ("100%", "200"), ("50%", "50"), ("50%", "100"), ("100%", r.as_str()), ("25%", "50"), (" 25% ", "25")];
assert_eq!(found.iter().map(|(a, b)| (*a, b.as_str())).collect::<Vec<_>>(), expected);
}
}
+128 -7
View File
@@ -175,7 +175,8 @@ pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, war
let cx = Ctx::new(xml, dpi);
let mut out = String::new();
let mut copied = 0;
for t in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "text" && n.tag_name().namespace() == Some(SVG_NS)) {
let is_text = |n: &XNode| n.is_element() && matches!(n.tag_name().name(), "text" | "flowRoot") && n.tag_name().namespace() == Some(SVG_NS);
for t in xml.descendants().filter(is_text) {
let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'"));
if t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") {
warnings.push(format!("{label} in a clip path ignored"));
@@ -191,11 +192,13 @@ pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, war
else {
continue;
};
let Some(q) = qname.strip_suffix("text").filter(|q| q.is_empty() || q.ends_with(':')) else { continue };
let flowed = t.tag_name().name() == "flowRoot";
let Some(q) = qname.strip_suffix(t.tag_name().name()).filter(|q| q.is_empty() || q.ends_with(':')) else { continue };
if cx.css.own(t, "display").as_deref() == Some("none") || r.start < copied {
continue;
}
let Some((text, paints)) = cx.build(xml, t, &label, warnings) else { continue };
let built = if flowed { cx.build_flow(t, &label, warnings) } else { cx.build(xml, t, &label, warnings) };
let Some((text, paints)) = built else { continue };
out.push_str(&svg[copied..r.start]);
placeholder(&mut out, q, &cx.css, t, &slots.prefix, slots.texts.len(), text.marker, &paints);
copied = r.end;
@@ -468,10 +471,11 @@ impl Ctx {
None => {
let id = href(tp)?.trim().strip_prefix('#')?;
let el = xml.descendants().find(|n| n.is_element() && n.attribute("id") == Some(id))?;
if el.tag_name().name() != "path" {
return None;
}
let mut bp = BezPath::from_svg(el.attribute("d")?).ok()?;
let d = match el.tag_name().name() {
"path" => el.attribute("d")?.to_string(),
_ => self.shape_path(el)?,
};
let mut bp = BezPath::from_svg(&d).ok()?;
bp.apply_affine(parse_transform(el.attribute("transform")));
bp
}
@@ -483,6 +487,123 @@ impl Ctx {
usable.then_some(bp)
}
/// The equivalent path of a basic shape (SVG 2 › Basic Shapes), as path data: what a
/// `<textPath>` that points at it follows. Circles and ellipses start at 3 o'clock and rounded
/// rects at the end of the top-left corner, all running clockwise; lines and polylines follow
/// their points. `None` for other elements and for shapes that render nothing.
fn shape_path(&self, el: XNode) -> Option<String> {
let (vw, vh) = self.viewport;
let len = |a: &str, pct: f64| el.attribute(a).and_then(|v| self.length(v, DEFAULT_FONT_SIZE, pct));
let x = |a: &str| len(a, vw).unwrap_or(0.0);
let y = |a: &str| len(a, vh).unwrap_or(0.0);
let points = || -> Vec<f64> { el.attribute("points").map(|v| list(v).map_while(|t| t.parse().ok()).collect()).unwrap_or_default() };
let ellipse = |cx: f64, cy: f64, rx: f64, ry: f64| {
(rx > 0.0 && ry > 0.0).then(|| {
format!(
"M{} {} A{rx} {ry} 0 0 1 {cx} {} A{rx} {ry} 0 0 1 {} {cy} A{rx} {ry} 0 0 1 {cx} {} A{rx} {ry} 0 0 1 {} {cy}Z",
cx + rx,
cy,
cy + ry,
cx - rx,
cy - ry,
cx + rx
)
})
};
match el.tag_name().name() {
"circle" => {
let r = len("r", ((vw * vw + vh * vh) / 2.0).sqrt())?;
ellipse(x("cx"), y("cy"), r, r)
}
"ellipse" => {
let (rx, ry) = (len("rx", vw), len("ry", vh));
let (rx, ry) = (rx.or(ry)?, ry.or(rx)?);
ellipse(x("cx"), y("cy"), rx, ry)
}
"rect" => {
let (x0, y0, w, h) = (x("x"), y("y"), len("width", vw)?, len("height", vh)?);
if w <= 0.0 || h <= 0.0 {
return None;
}
let (rx, ry) = (len("rx", vw).filter(|v| *v > 0.0), len("ry", vh).filter(|v| *v > 0.0));
let (rx, ry) = (rx.or(ry).unwrap_or(0.0).min(w / 2.0), ry.or(rx).unwrap_or(0.0).min(h / 2.0));
if rx <= 0.0 || ry <= 0.0 {
return Some(format!("M{x0} {y0} H{} V{} H{x0}Z", x0 + w, y0 + h));
}
let (x1, y1) = (x0 + w, y0 + h);
Some(format!(
"M{} {y0} H{} A{rx} {ry} 0 0 1 {x1} {} V{} A{rx} {ry} 0 0 1 {} {y1} H{} A{rx} {ry} 0 0 1 {x0} {} V{} A{rx} {ry} 0 0 1 {} {y0}Z",
x0 + rx,
x1 - rx,
y0 + ry,
y1 - ry,
x1 - rx,
x0 + rx,
y1 - ry,
y0 + ry,
x0 + rx
))
}
"line" => Some(format!("M{} {} L{} {}", x("x1"), y("y1"), x("x2"), y("y2"))),
tag @ ("polyline" | "polygon") => {
let pts = points();
let mut d = String::new();
for (i, [x, y]) in pts.as_chunks::<2>().0.iter().enumerate() {
let _ = write!(d, "{}{x} {y} ", if i == 0 { 'M' } else { 'L' });
}
if d.is_empty() {
return None;
}
if tag == "polygon" {
d.push('Z');
}
Some(d)
}
_ => None,
}
}
/// Read one Inkscape `<flowRoot>` (SVG 1.2 flowed text, which browsers don't draw but
/// Inkscape writes) as area type: its frame the first `<rect>` of its `<flowRegion>`, a
/// paragraph per `<flowPara>`, styled as `<text>` is. Other regions are left out with a warning.
fn build_flow(&self, t: XNode, label: &str, warnings: &mut Vec<String>) -> Option<(PendingText, Vec<String>)> {
let (vw, vh) = self.viewport;
let region = t.children().find(|c| c.is_element() && c.tag_name().name() == "flowRegion");
let rect = region.and_then(|r| r.children().find(|c| c.is_element() && c.tag_name().name() == "rect"));
let size = |n: XNode, a: &str, pct: f64| n.attribute(a).and_then(|v| self.length(v, DEFAULT_FONT_SIZE, pct));
let frame = rect.and_then(|r| {
let (w, h) = (size(r, "width", vw)?, size(r, "height", vh)?);
(w > 0.0 && h > 0.0).then(|| (r, Rect::new(0.0, 0.0, w, h)))
});
let Some((r, frame)) = frame else {
warnings.push(format!("{label}: flowed text (flowRoot) without a rectangle region was left out"));
return None;
};
let origin = Affine::translate((size(r, "x", vw).unwrap_or(0.0), size(r, "y", vh).unwrap_or(0.0)));
let paras: Vec<XNode> = t.descendants().filter(|n| n.is_element() && n.tag_name().name() == "flowPara").collect();
let collapse = |n: XNode| {
n.descendants().filter(XNode::is_text).filter_map(|c| c.text()).collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ")
};
let text = paras.iter().map(|p| collapse(*p)).collect::<Vec<_>>().join("\n");
if text.trim().is_empty() {
return None;
}
let style = self.elem(paras.first().copied().unwrap_or(t)).style.clone();
let mut obj = TextObject::point(Point::ZERO, &text, style);
obj.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(frame) };
obj.xf = parse_transform(r.attribute("transform")) * origin;
let servers = vec![(None, None); obj.runs.len()];
let b = obj.xf.transform_rect_bbox(frame);
// A size, place or transform past what numbers hold (`width="1e999"`) has no frame to set type in.
if ![b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) {
warnings.push(format!("{label}: flowed text (flowRoot) with a region too large to place was left out"));
return None;
}
let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0));
let name = t.attribute("id").unwrap_or("").to_string();
Some((PendingText { name, obj, servers, paints: 0, non_scaling: false, under: None, marker }, Vec::new()))
}
/// Read one `<text>`: the pending text and its `url(#…)` paints.
fn build(&self, xml: &roxmltree::Document, t: XNode, label: &str, warnings: &mut Vec<String>) -> Option<(PendingText, Vec<String>)> {
let preserve = t.ancestors().filter(|a| a.is_element()).find_map(|a| a.attribute((roxmltree::NS_XML_URI, "space"))) == Some("preserve");
+76
View File
@@ -181,3 +181,79 @@ fn reflect_and_repeat_match_resvg() {
assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.01);
}
}
/// #1557: `transform` as a CSS property, in CSS syntax, moves the object (and wins over the
/// attribute, as in CSS), from a `style` attribute or a `<style>` rule.
#[test]
fn css_transforms_move_objects() {
let d = import_with(
&svg(r#"<style>.r { transform: rotate(90deg) }</style>
<rect id="a" x="0" y="0" width="50" height="50" fill="red" style="transform: translate(100px, 100px)"/>
<rect id="b" x="0" y="0" width="50" height="50" fill="blue" transform="translate(0 100)" style="transform: translateX(100px)"/>
<rect id="c" class="r" x="0" y="0" width="50" height="20" fill="green"/>"#),
&ImportOptions::default(),
)
.unwrap()
.0;
let b: Vec<Rect> = art(&d).iter().map(|n| n.geometric_bounds().unwrap()).collect();
let near =
|r: Rect, x0: f64, y0: f64, x1: f64, y1: f64| [(r.x0, x0), (r.y0, y0), (r.x1, x1), (r.y1, y1)].iter().all(|(a, b)| (a - b).abs() < 1e-6);
assert!(near(b[0], 100.0, 100.0, 150.0, 150.0), "{:?}", b[0]);
assert!(near(b[1], 100.0, 0.0, 150.0, 50.0), "{:?}", b[1]);
assert!(near(b[2], -20.0, 0.0, 0.0, 50.0), "{:?}", b[2]);
}
/// #1561: percentages in a `<symbol>` with a `viewBox` are of the `viewBox`, so the symbol draws as
/// the same one written with numbers.
#[test]
fn percentages_in_a_symbol_are_of_its_view_box() {
let drawing = |w: &str, h: &str, cx: &str, cy: &str| {
let s = svg(&format!(
r##"<defs><symbol id="s" viewBox="0 0 100 50"><rect width="{w}" height="{h}" fill="red"/><circle cx="{cx}" cy="{cy}" r="20" fill="blue"/></symbol></defs>
<use xlink:href="#s" x="20" y="10" width="160" height="100"/>"##
));
render_artboard(&import_with(&s, &ImportOptions::default()).unwrap().0)
};
assert_similar(&drawing("100%", "100%", "25%", "50%"), &drawing("100", "50", "25", "25"), 24.0, 0.001);
}
/// #1598: an unsupported filter's warning names the filter, its primitives and the object, and
/// says it is drawn without it (not "a group": usvg wraps the element in one).
#[test]
fn unsupported_filters_are_named_in_the_warning() {
let body = r#"<filter id="f"><feColorMatrix type="saturate" values="0"/></filter><filter id="t"><feTurbulence baseFrequency="0.05"/></filter>
<rect x="20" y="20" width="60" height="60" fill="red" filter="url(#f)"/>
<rect id="r" x="100" y="20" width="60" height="60" fill="blue" filter="url(#t)"/>"#;
let (d, w) = import_with_report(&svg(body)).unwrap();
assert!(w.iter().any(|w| w == "filter 'f' (feColorMatrix) on an object isn't supported; drawn without it"), "{w:?}");
assert!(w.iter().any(|w| w == "filter 't' (feTurbulence) on 'r' isn't supported; drawn without it"), "{w:?}");
assert_eq!(art(&d).iter().filter(|n| n.path_data().is_some()).count(), 2, "both drawn");
}
/// #1601: Inkscape's pages become artboards, at their places (through the viewBox) and with their
/// labels.
#[test]
fn inkscape_pages_become_artboards() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" width="200" height="200" viewBox="0 0 100 100">
<sodipodi:namedview><inkscape:page x="0" y="0" width="100" height="100" inkscape:label="Front"/><inkscape:page x="120" y="0" width="60" height="150"/><inkscape:page x="0" y="0" width="0" height="10"/></sodipodi:namedview>
<rect x="10" y="10" width="80" height="80" fill="red"/></svg>"#;
let d = import_with(svg, &ImportOptions::default()).unwrap().0;
let b: Vec<(String, Rect)> = d.artboards.iter().map(|a| (a.name.clone(), a.rect)).collect();
assert_eq!(b, vec![("Front".to_string(), Rect::new(0.0, 0.0, 200.0, 200.0)), ("Artboard 2".to_string(), Rect::new(240.0, 0.0, 360.0, 300.0))]);
// Hostile boxes (too large to add up, not numbers, negative) are no pages, and at most 1,000 are read.
let pages = r#"<inkscape:page x="1e308" y="0" width="1e308" height="10"/><inkscape:page x="NaN" y="0" width="10" height="10"/><inkscape:page width="inf" height="10"/><inkscape:page width="-5" height="10"/>"#.to_string()
+ &r#"<inkscape:page width="10" height="10"/>"#.repeat(1500);
let hostile = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="50" height="50"><g>{pages}</g></svg>"#
);
let d = import_with(&hostile, &ImportOptions::default()).unwrap().0;
assert_eq!(d.artboards.len(), 1000);
assert!(d.artboards.iter().all(|a| a.rect == Rect::new(0.0, 0.0, 10.0, 10.0)), "{:?}", d.artboards.first());
// A file without pages keeps its one artboard.
let d = import_with(&svg_doc(), &ImportOptions::default()).unwrap().0;
assert_eq!(d.artboards.len(), 1);
}
fn svg_doc() -> String {
svg(r#"<rect x="10" y="10" width="20" height="20"/>"#)
}
+111
View File
@@ -518,3 +518,114 @@ fn gradients_on_scaled_text_stay_in_place() {
let (start, end) = (t.xf * geom.start, t.xf * geom.end);
assert!(start.distance(Point::new(40.0, 0.0)) < 1e-6 && end.distance(Point::new(240.0, 0.0)) < 1e-6, "{start:?} {end:?}");
}
/// The start of a type-on-a-path object's path and a point a little way along it, in document
/// space.
fn path_start(t: &TextObject) -> (Point, Point) {
use vectorcraft_geom::kurbo::{ParamCurve, PathEl};
let TextKind::OnPath { path, .. } = &t.kind else { panic!("{:?}", t.kind) };
let mut bp = path.to_bezpath();
bp.apply_affine(t.xf);
let PathEl::MoveTo(p0) = bp.elements()[0] else { panic!("{bp:?}") };
let next = bp.segments().next().unwrap().eval(0.05);
(p0, next)
}
#[test]
fn text_path_follows_basic_shapes_as_their_equivalent_paths() {
let (d, w) = import_with_report(&svg(r##"<circle id="c" cx="150" cy="150" r="100"/>
<ellipse id="e" cx="150" cy="150" rx="80" ry="40" transform="translate(10 0)"/>
<rect id="r" x="20" y="30" width="100" height="60" rx="10"/>
<rect id="s" x="20" y="30" width="100" height="60"/>
<line id="l" x1="10" y1="200" x2="210" y2="200"/>
<polyline id="pl" points="10,10 110,10 110,60"/>
<polygon id="pg" points="10 10, 60 10, 35 50"/>
<text font-size="10"><textPath xlink:href="#c">TEXT ON A CIRCLE</textPath></text>
<text font-size="10"><textPath href="#e">Ellipse</textPath></text>
<text font-size="10"><textPath href="#r">Rounded</textPath></text>
<text font-size="10"><textPath href="#s">Square</textPath></text>
<text font-size="10"><textPath href="#l">Line</textPath></text>
<text font-size="10"><textPath href="#pl">Polyline</textPath></text>
<text font-size="10"><textPath href="#pg">Polygon</textPath></text>"##))
.unwrap();
assert!(!w.iter().any(|w| w.contains("textPath")), "{w:?}");
let t = texts(&d);
assert_eq!(t.len(), 7);
assert!(t.iter().all(|t| matches!(t.kind, TextKind::OnPath { .. })), "{:?}", t.iter().map(|t| &t.kind).collect::<Vec<_>>());
let near = |a: Point, b: Point| (a - b).hypot() < 1e-6;
// Circle: starts at 3 o'clock and runs clockwise (down first, y pointing down), around the circle.
let (p0, next) = path_start(t[0]);
assert!(near(p0, Point::new(250.0, 150.0)) && next.y > p0.y && next.x < p0.x, "{p0:?} {next:?}");
let TextKind::OnPath { path, .. } = &t[0].kind else { panic!("not on a path") };
let b = t[0].xf.transform_rect_bbox(path.bounds().unwrap());
assert!(close(b.x0, 50.0, 1e-6) && close(b.y0, 50.0, 1e-6) && close(b.x1, 250.0, 1e-6) && close(b.y1, 250.0, 1e-6), "{b:?}");
assert!(path.is_closed());
// Ellipse, with the shape's own transform.
let (p0, next) = path_start(t[1]);
assert!(near(p0, Point::new(240.0, 150.0)) && next.y > p0.y, "{p0:?} {next:?}");
// Rounded rect: after the top-left corner, along the top edge to the right.
let (p0, next) = path_start(t[2]);
assert!(near(p0, Point::new(30.0, 30.0)) && next.x > p0.x && close(next.y, 30.0, 1e-9), "{p0:?} {next:?}");
let (p0, next) = path_start(t[3]);
assert!(near(p0, Point::new(20.0, 30.0)) && next.x > p0.x, "{p0:?} {next:?}");
// Line, polyline and polygon follow their points.
assert!(near(path_start(t[4]).0, Point::new(10.0, 200.0)));
assert!(near(path_start(t[5]).0, Point::new(10.0, 10.0)));
let TextKind::OnPath { path, .. } = &t[6].kind else { panic!("not on a path") };
assert!(path.is_closed() && near(path_start(t[6]).0, Point::new(10.0, 10.0)));
assert_eq!(t[0].plain_text(), "TEXT ON A CIRCLE");
}
#[test]
fn text_path_to_an_empty_shape_still_falls_back_to_point_type() {
let (d, w) =
import_with_report(&svg(r##"<circle id="c" cx="5" cy="5" r="0"/><text x="5" y="10"><textPath href="#c">Zero</textPath></text>"##)).unwrap();
assert!(matches!(only_text(&d).kind, TextKind::Point));
assert!(w.iter().any(|w| w.contains("textPath")), "{w:?}");
}
/// #1557: the `font` shorthand sets the type's size, weight and style.
#[test]
fn the_font_shorthand_styles_type() {
let d = open(r#"<text x="10" y="40" style="font: italic bold 30px sans-serif">Deco</text>"#);
let t = only_text(&d);
let st = &t.runs[0].style;
assert!(close(st.size * t.xf.determinant().abs().sqrt(), 30.0, 1e-6), "{}", st.size);
let style = st.font_style.to_ascii_lowercase();
assert!(style.contains("bold") && style.contains("italic"), "{style}");
}
/// Inkscape's flowed text (`<flowRoot>`) becomes area type in its region's rectangle, a paragraph
/// per `<flowPara>`; another kind of region is left out with a warning, not silently.
#[test]
fn flowed_text_becomes_area_type() {
let (d, w) = import_with_report(&svg(
r#"<flowRoot style="font-size:16px;font-family:sans-serif"><flowRegion><rect x="20" y="30" width="100" height="160"/></flowRegion><flowPara>This is flowed text</flowPara><flowPara>Second <flowSpan>one</flowSpan></flowPara></flowRoot>
<flowRoot><flowRegion><path d="M0 0 L10 0 L5 10 Z"/></flowRegion><flowPara>Lost</flowPara></flowRoot>"#,
))
.unwrap();
let t = only_text(&d);
assert_eq!(t.plain_text(), "This is flowed text\nSecond one");
let TextKind::Area { frame } = &t.kind else { panic!("{:?}", t.kind) };
let b = t.xf.transform_rect_bbox(frame.bounds().unwrap());
assert!(close(b.x0, 20.0, 1e-6) && close(b.y0, 30.0, 1e-6) && close(b.x1, 120.0, 1e-6) && close(b.y1, 190.0, 1e-6), "{b:?}");
assert!(close(t.runs[0].style.size * t.xf.determinant().abs().sqrt(), 16.0, 1e-6));
assert!(w.iter().any(|w| w.contains("flowRoot")), "{w:?}");
}
/// Flowed text that can't be read is left out with a warning each, and never stops the import: no
/// region, a region too large for numbers, a flowRoot inside a flowRoot (read once, as the outer one).
#[test]
fn flowed_text_that_cannot_be_read_is_reported() {
let (d, w) = import_with_report(&svg(
r#"<flowRoot id="none"><flowPara>No region</flowPara></flowRoot>
<flowRoot id="huge"><flowRegion><rect width="1e999" height="10"/></flowRegion><flowPara>Huge</flowPara></flowRoot>
<flowRoot id="far"><flowRegion><rect x="1e308" width="1e308" height="10" transform="scale(1e308)"/></flowRegion><flowPara>Far</flowPara></flowRoot>
<flowRoot id="outer"><flowRegion><rect width="50" height="50"/></flowRegion><flowPara>Outer</flowPara><flowRoot><flowRegion><rect width="5" height="5"/></flowRegion><flowPara>inner</flowPara></flowRoot></flowRoot>"#,
))
.unwrap();
assert_eq!(only_text(&d).plain_text(), "Outer\ninner");
for id in ["none", "huge", "far"] {
assert!(w.iter().any(|w| w.contains(id) && w.contains("flowRoot")), "{id}: {w:?}");
}
}
+27
View File
@@ -29,6 +29,33 @@ fn doc() -> Document {
d
}
/// A large palette should create one class per distinct declaration, preserving first-use
/// numbers even when earlier styles recur after hundreds of others.
#[test]
fn many_css_styles_preserve_first_use_numbers() {
let mut d = Document::new(300.0, 10.0);
let layer = d.layers[0].id;
let colors = (0u16..256).chain([0, 128, 255]);
for (x, color) in colors.enumerate() {
let x = x as f64;
let appearance = Appearance::basic(Paint::solid(Color::rgb8(color as u8, 31, 63)), Paint::None, 0.0);
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 0.5, 1.0)), appearance);
d.insert(Some(layer), usize::MAX, n).unwrap();
}
for (styling, definition_marker) in [(Styling::InternalCss, ".cls-"), (Styling::StyleEntities, "<!ENTITY st")] {
let svg = export(&d, &ExportOptions { styling, ..Default::default() });
let paths: Vec<_> = svg.lines().filter(|line| line.contains("<path ")).collect();
assert_eq!(paths.len(), 259, "{styling:?}");
for (i, path) in paths.iter().enumerate() {
let number = if i < 256 { i + 1 } else { [1, 129, 256][i - 256] };
let expected = if styling == Styling::InternalCss { format!("class=\"cls-{number}\"") } else { format!("style=\"&st{number};\"") };
assert!(path.contains(&expected), "path {i} should reference {expected}: {path}");
}
assert_eq!(svg.matches(definition_marker).count(), 256, "{styling:?}");
}
}
#[test]
fn every_combination_reads_back() {
let d = doc();
+44 -2
View File
@@ -41,8 +41,19 @@ pub struct OtFeatures {
pub ordinals: bool,
/// Swashes (`swsh`).
pub swash: bool,
/// The features of [`EXTRA_FEATURES`] turned on, one bit each (off by default): stylistic
/// sets, alternates, titling, proportional and lining figures, superscript and subscript.
pub extra: u32,
}
/// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]:
/// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum`
/// and lining `lnum` figures, superscript `sups` and subscript `subs`.
pub const EXTRA_FEATURES: [&str; 26] = [
"ss01", "ss02", "ss03", "ss04", "ss05", "ss06", "ss07", "ss08", "ss09", "ss10", "ss11", "ss12", "ss13", "ss14", "ss15", "ss16", "ss17", "ss18",
"ss19", "ss20", "salt", "titl", "pnum", "lnum", "sups", "subs",
];
impl Default for OtFeatures {
fn default() -> Self {
Self {
@@ -56,6 +67,7 @@ impl Default for OtFeatures {
tabular_figures: false,
ordinals: false,
swash: false,
extra: 0,
}
}
}
@@ -89,13 +101,14 @@ impl OtFeatures {
v.push(if on { tag.to_string() } else { format!("-{tag}") });
}
}
v.extend(EXTRA_FEATURES.iter().enumerate().filter(|(i, _)| self.extra & (1 << i) != 0).map(|(_, t)| t.to_string()));
v
}
/// Is `tag` (optionally prefixed with `-` or `+`) one this set understands?
pub fn known_tag(tag: &str) -> bool {
let t = tag.trim_start_matches(['-', '+']);
matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh")
matches!(t, "liga" | "calt" | "dlig" | "smcp" | "frac" | "onum" | "tnum" | "ordn" | "swsh") || EXTRA_FEATURES.contains(&t)
}
/// This set with `tags` applied on top.
@@ -116,7 +129,15 @@ impl OtFeatures {
"tnum" => o.tabular_figures = on,
"ordn" => o.ordinals = on,
"swsh" => o.swash = on,
_ => {}
_ => {
if let Some(i) = EXTRA_FEATURES.iter().position(|e| *e == t) {
if on {
o.extra |= 1 << i;
} else {
o.extra &= !(1 << i);
}
}
}
}
}
self
@@ -155,6 +176,11 @@ impl OtFeatures {
v.push(f(tag, true));
}
}
for (i, tag) in EXTRA_FEATURES.iter().enumerate() {
if let (true, Ok(t)) = (s.extra & (1 << i) != 0, <[u8; 4]>::try_from(tag.as_bytes())) {
v.push(f(&t, true));
}
}
if self.vertical {
// Not `vrt2`: its glyphs are already turned on their side, and the layout turns Latin
// and digits itself (they would lie upside down, and tate-chu-yoko break).
@@ -230,6 +256,22 @@ pub fn explicit_ligatures<S: AsRef<str>>(features: &[S]) -> bool {
#[cfg(test)]
mod tests {
use super::*;
/// #1607: stylistic sets, alternates, titling, figure styles and superscript/subscript are
/// known, round-trip through the stored tags and reach the shaper.
#[test]
fn extra_features_are_kept_and_shaped() {
for t in ["ss01", "ss20", "-ss07", "salt", "titl", "pnum", "lnum", "sups", "subs"] {
assert!(OtFeatures::known_tag(t), "{t}");
}
assert!(!OtFeatures::known_tag("ss21"));
let o = OtFeatures::from_tags(["ss03", "pnum", "sups", "-liga"]);
assert_eq!(o.to_tags(), vec!["-liga", "ss03", "pnum", "sups"]);
assert_eq!(OtFeatures::from_tags(["ss03", "-ss03"]).to_tags(), Vec::<String>::new(), "turned off again");
let st = CharStyle { features: vec!["ss05".into(), "titl".into()], ..CharStyle::default() };
let tags: Vec<String> = OtFeatures::default().resolve(&st).iter().filter(|f| f.value == 1).map(|f| f.tag.to_string()).collect();
assert!(tags.contains(&"ss05".to_string()) && tags.contains(&"titl".to_string()), "{tags:?}");
}
use crate::{FontDb, layout};
use vectorcraft_doc::TextObject;
use vectorcraft_geom::Point;
+253
View File
@@ -0,0 +1,253 @@
//! The font folders fontconfig is set up with (Linux and the other Unix desktops): the `<dir>`
//! entries of its configuration, `/etc/fonts/fonts.conf` and what that includes (`conf.d`, the
//! user's own files). Distributions and packages add folders outside the usual ones there: TeX
//! fonts under `/usr/share/texmf/fonts`, say, which `fc-list` and other apps then show (#1529).
//!
//! The format is fontconfig's documented one
//! (<https://www.freedesktop.org/software/fontconfig/fontconfig-user.html>, "Configuration file
//! format"): `<dir prefix="…">` and `<include prefix="…">` are all that is read here. The files are
//! read as they are: fontconfig itself (a library, or `fc-list`) isn't asked.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// The most configuration files read, and the largest one (they are a few kilobytes each).
const MAX_FILES: usize = 512;
const MAX_FILE_BYTES: u64 = 1 << 20;
/// The most folders taken, and how deep files may include files.
const MAX_DIRS: usize = 256;
const MAX_DEPTH: usize = 8;
/// A `<dir>` (a font folder) or an `<include>` (another configuration file, or a folder of them)
/// with its `prefix` attribute and its text.
#[derive(Debug, PartialEq)]
struct Entry {
include: bool,
prefix: String,
path: String,
}
/// The `<dir>` and `<include>` elements of a configuration file, in order. Anything that isn't
/// one of them, comments included, is skipped; a file that isn't well formed gives what was read
/// before the damage.
fn entries(xml: &str) -> Vec<Entry> {
let mut out = vec![];
let mut rest = xml;
while let Some(at) = rest.find('<') {
rest = rest.get(at + 1..).unwrap_or_default();
if let Some(comment) = rest.strip_prefix("!--") {
rest = comment.split_once("-->").map_or("", |(_, after)| after);
continue;
}
let Some((tag, after)) = rest.split_once('>') else { break };
let name = tag.split_whitespace().next().unwrap_or_default();
if !matches!(name, "dir" | "include") || tag.ends_with('/') {
continue;
}
let Some((text, after)) = after.split_once("</") else { break };
rest = after;
// No element is inside these: text with a tag in it isn't a path.
if text.contains('<') {
continue;
}
let path = text.trim().replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'");
if !path.is_empty() {
out.push(Entry { include: name == "include", prefix: attribute(tag, "prefix").unwrap_or_default().to_string(), path });
}
}
out
}
/// The value of attribute `name` in a start tag's text.
fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let mut rest = tag;
while let Some(at) = rest.find(name) {
let before_ok = rest.get(..at).is_some_and(|b| b.ends_with(char::is_whitespace));
rest = rest.get(at + name.len()..)?;
let Some(value) = rest.trim_start().strip_prefix('=') else { continue };
let value = value.trim_start();
let quote = value.chars().next().filter(|q| matches!(q, '"' | '\''))?;
let (value, _) = value.get(1..)?.split_once(quote)?;
if before_ok {
return Some(value);
}
}
None
}
/// Where the user's files are: the home folder and the XDG data and configuration folders.
#[derive(Clone, Debug, Default)]
pub(crate) struct Places {
pub home: Option<PathBuf>,
pub data_home: Option<PathBuf>,
pub config_home: Option<PathBuf>,
}
impl Places {
/// The path an entry of the file `from` names: `~` is the home folder, `prefix="xdg"` the XDG
/// data folder (a `<dir>`) or configuration folder (an `<include>`), `prefix="relative"` the
/// file's own folder, as is an `<include>` that isn't a full path. A `<dir>` relative to the
/// folder the app was started in is none.
fn resolve(&self, e: &Entry, from: &Path) -> Option<PathBuf> {
// `~` alone or before a `/`: another user's home (`~name`) isn't looked up.
if let Some(rest) = e.path.strip_prefix('~').filter(|rest| rest.is_empty() || rest.starts_with('/')) {
return Some(self.home.as_ref()?.join(rest.trim_start_matches('/')));
}
let path = Path::new(&e.path);
let base = match e.prefix.as_str() {
"xdg" if e.include => self.config_home.as_deref(),
"xdg" => self.data_home.as_deref(),
_ if path.is_absolute() => return Some(path.to_path_buf()),
"relative" => from.parent(),
_ if e.include => from.parent(),
_ => None,
};
Some(base?.join(path)).filter(|p| p.is_absolute())
}
}
/// The font folders the configuration file `root` and the files it includes list, in order, each
/// once. Files that are missing or can't be read list none.
pub(crate) fn font_dirs(root: &Path, places: &Places) -> Vec<PathBuf> {
let mut read = Reader { places, files: HashSet::new(), dirs: vec![] };
read.file(root, 0);
read.dirs
}
struct Reader<'a> {
places: &'a Places,
/// The files read so far (a file including itself is read once).
files: HashSet<PathBuf>,
dirs: Vec<PathBuf>,
}
impl Reader<'_> {
fn file(&mut self, path: &Path, depth: usize) {
if depth > MAX_DEPTH || self.files.len() >= MAX_FILES || !self.files.insert(path.to_path_buf()) {
return;
}
// A folder of configuration files: those named as fontconfig reads them (a digit first,
// `.conf` last), in name order.
if let Ok(list) = std::fs::read_dir(path) {
let mut files: Vec<PathBuf> = list
.flatten()
.take(MAX_FILES)
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.starts_with(|c: char| c.is_ascii_digit()) && name.ends_with(".conf")
})
.map(|e| e.path())
.collect();
files.sort();
for f in files {
self.file(&f, depth + 1);
}
return;
}
// A file that is missing, too large or not text lists nothing.
if !std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.len() <= MAX_FILE_BYTES) {
return;
}
let Ok(text) = std::fs::read_to_string(path) else { return };
for e in entries(&text) {
let Some(to) = self.places.resolve(&e, path) else { continue };
if e.include {
self.file(&to, depth + 1);
} else if self.dirs.len() < MAX_DIRS && !self.dirs.contains(&to) {
self.dirs.push(to);
}
}
}
}
/// The configuration file fontconfig starts from: `FONTCONFIG_FILE`, else `fonts.conf` in
/// `FONTCONFIG_PATH`, else `/etc/fonts/fonts.conf`.
pub(crate) fn root() -> PathBuf {
let var = |name| std::env::var_os(name).map(PathBuf::from).filter(|p| p.is_absolute());
var("FONTCONFIG_FILE").or_else(|| var("FONTCONFIG_PATH").map(|d| d.join("fonts.conf"))).unwrap_or_else(|| "/etc/fonts/fonts.conf".into())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(include: bool, prefix: &str, path: &str) -> Entry {
Entry { include, prefix: prefix.into(), path: path.into() }
}
#[test]
fn dirs_and_includes_are_read_and_the_rest_is_skipped() {
let xml = r#"<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<description>Default configuration file</description>
<!-- <dir>/commented/out</dir> -->
<dir>/usr/share/fonts</dir>
<dir prefix="xdg">fonts</dir>
<dir prefix = 'relative' salt="x"> more &amp; more </dir>
<dir>~/.fonts</dir>
<dir></dir>
<cachedir>/var/cache/fontconfig</cachedir>
<match target="pattern"><test qual="any" name="family"><string>mono</string></test></match>
<include ignore_missing="yes">conf.d</include>
<include ignore_missing="yes" prefix="xdg">fontconfig/fonts.conf</include>
<dir>/cut/off"#;
assert_eq!(
entries(xml),
[
entry(false, "", "/usr/share/fonts"),
entry(false, "xdg", "fonts"),
entry(false, "relative", "more & more"),
entry(false, "", "~/.fonts"),
entry(true, "", "conf.d"),
entry(true, "xdg", "fontconfig/fonts.conf"),
]
);
// Damaged files and files that aren't XML read as nothing, without a panic.
for junk in
["", "<", "<dir", "<dir>", "<dir><dir></dir>", "</dir>", "<!--", "<dir prefix=>x</dir", "\u{0}<dir prefix=\"", "<dir prefix='é>ü</dir>"]
{
let _ = entries(junk);
}
assert_eq!(attribute("dir notprefix=\"a\" prefix=\"b\"", "prefix"), Some("b"));
}
/// A configuration as distributions lay it out: the main file lists the usual folders and
/// includes `conf.d`, where a package's file adds its own folder (TeX fonts, #1529) and
/// another includes the user's files.
#[test]
fn folders_of_included_files_are_listed_once_in_order() {
let tmp = std::env::temp_dir().join(format!("vc-fontconfig-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let (etc, home) = (tmp.join("etc/fonts"), tmp.join("home"));
let write = |path: PathBuf, body: &str| {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, format!("<fontconfig>{body}</fontconfig>")).unwrap();
};
let texmf = tmp.join("usr/share/texmf/fonts/opentype");
write(
etc.join("fonts.conf"),
&format!(
"<dir>{}</dir><dir prefix=\"xdg\">fonts</dir><dir>~/.fonts</dir><dir>fonts-of-the-working-folder</dir>\
<include ignore_missing=\"yes\">conf.d</include><include>{}</include><include>fonts.conf</include>",
tmp.join("usr/share/fonts").display(),
etc.join("missing.conf").display()
),
);
write(etc.join("conf.d/09-texlive.conf"), &format!("<dir>{}</dir>", texmf.display()));
write(etc.join("conf.d/50-user.conf"), "<include ignore_missing=\"yes\" prefix=\"xdg\">fontconfig/fonts.conf</include>");
write(etc.join("conf.d/README"), "<dir>/not/a/conf/file</dir>");
write(etc.join("conf.d/user.conf"), "<dir>/no/digit/first</dir>");
write(home.join(".config/fontconfig/fonts.conf"), &format!("<dir prefix=\"relative\">mine</dir><dir>{}</dir>", texmf.display()));
let places = Places { home: Some(home.clone()), data_home: Some(home.join(".local/share")), config_home: Some(home.join(".config")) };
assert_eq!(
font_dirs(&etc.join("fonts.conf"), &places),
[tmp.join("usr/share/fonts"), home.join(".local/share/fonts"), home.join(".fonts"), texmf, home.join(".config/fontconfig/mine")]
);
// Without a home folder, what needs one is left out; a missing file lists nothing.
assert_eq!(font_dirs(&etc.join("fonts.conf"), &Places::default()).len(), 2);
assert!(font_dirs(&tmp.join("none.conf"), &places).is_empty());
let _ = std::fs::remove_dir_all(&tmp);
}
}
+10 -1
View File
@@ -1269,12 +1269,21 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
// list their own data folders), relative paths ignored as the XDG spec says.
let xdg = |var| std::env::var_os(var).map(|v| std::env::split_paths(&v).filter(|p| p.is_absolute()).collect::<Vec<_>>()).unwrap_or_default();
let data_home = xdg("XDG_DATA_HOME").into_iter().next().or_else(|| home.as_ref().map(|h| h.join(".local/share")));
dirs.extend(data_home.into_iter().chain(xdg("XDG_DATA_DIRS")).map(|d| d.join("fonts")));
dirs.extend(data_home.iter().cloned().chain(xdg("XDG_DATA_DIRS")).map(|d| d.join("fonts")));
if let Some(h) = &home {
dirs.push(h.join(".fonts"));
}
// In a Flatpak sandbox, the host's fonts (system, local and the user's).
dirs.extend(["/run/host/fonts", "/run/host/local-fonts", "/run/host/user-fonts"].map(Into::into));
// The folders fontconfig is set up with outside those: packages add their own (TeX fonts
// under `/usr/share/texmf/fonts`, #1529), and `fc-list` and other apps show their fonts.
let config_home = xdg("XDG_CONFIG_HOME").into_iter().next().or_else(|| home.as_ref().map(|h| h.join(".config")));
let places = crate::fontconfig::Places { home: home.clone(), data_home, config_home };
for dir in crate::fontconfig::font_dirs(&crate::fontconfig::root(), &places) {
if !dirs.iter().any(|known| dir.starts_with(known)) {
dirs.push(dir);
}
}
}
dirs.extend(app_font_dir().map(Path::to_path_buf));
#[cfg(not(target_arch = "wasm32"))]
+55 -6
View File
@@ -1013,6 +1013,8 @@ struct Pen<'r> {
/// Leading measured from em box top to em box top: where the next line's em box top goes.
model: LeadingModel,
next_top: Option<f64>,
/// The narrowest word no line from here down is wide enough for (see [`Pen::place`]).
no_room_for: f64,
}
/// The largest number of steps layout takes to move a line down a frame cell to find room for it:
@@ -1020,10 +1022,27 @@ struct Pen<'r> {
const MAX_STEPS: usize = 100_000;
impl Pen<'_> {
/// Baseline and horizontal span for a line with estimated metrics `est` and indents; `None` =
/// the frame is full (overflow).
/// Baseline and horizontal span for a line with estimated metrics `est` and indents that starts
/// with a word `word` wide; `None` = the frame is full (overflow).
/// In a frame that isn't a rectangle, lines too short for the word are left empty and it moves
/// down to the first one wide enough. Only when no line is does it go on the first line with
/// room for any text, and get broken there.
/// The `bool` is true for a further span at the previous line's baseline.
fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64) -> Option<(f64, f64, f64, bool)> {
fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64, word: f64) -> Option<(f64, f64, f64, bool)> {
if word > 0.0 && word < self.no_room_for {
let mut pen = self.clone();
if let Some(placed) = pen.place_at_least(est, ind_l, ind_r, word) {
*self = pen;
return Some(placed);
}
// Lines further down are no wider for this word: don't search for it, or a wider one, again.
self.no_room_for = word;
}
self.place_at_least(est, ind_l, ind_r, 0.0)
}
/// [`Pen::place`], skipping lines of a non-rectangular frame narrower than `min`.
fn place_at_least(&mut self, est: Metrics, ind_l: f64, ind_r: f64, min: f64) -> Option<(f64, f64, f64, bool)> {
let Some(regions) = self.regions else {
let b = self.prev.map_or(0.0, |b| self.next_baseline(b, est));
return Some((b, f64::NEG_INFINITY, f64::INFINITY, false));
@@ -1046,7 +1065,12 @@ impl Pen<'_> {
if baseline + est.desc > r.bottom() + 0.01 {
break;
}
let fits = |&(a, b): &(f64, f64)| b - a - ind_l - ind_r > est.asc.max(1.0);
// A rectangle's lines are all as wide: none below has more room for the word.
let min = if r.rect || r.polys.is_empty() { 0.0 } else { min };
let fits = |&(a, b): &(f64, f64)| {
let room = b - a - ind_l - ind_r;
room > est.asc.max(1.0) && room + EPS >= min
};
if r.wraps.is_empty() {
match r.span(baseline - est.asc, baseline + est.desc) {
Some(s) if fits(&s) => return Some((baseline, s.0, s.1, false)),
@@ -1114,6 +1138,25 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S
(at - rel).max(0.0)
}
/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces
/// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it.
fn word_width(g: &[SGlyph], i: usize) -> f64 {
let mut w = 0.0;
for gl in g.get(i..).unwrap_or_default() {
if gl.is_space() {
break;
}
if gl.is_soft_hyphen() {
return w + hyphen_glyph(gl).adv;
}
w += gl.adv;
if gl.break_after() {
break;
}
}
w
}
/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`.
/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it.
fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, burasagari: Burasagari, kinsoku: Kinsoku) -> (usize, bool) {
@@ -1450,7 +1493,10 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
while widths.len() < sg.len() {
let first = widths.is_empty();
let ind_l = para.left_indent + if first { para.first_line_indent } else { 0.0 };
let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent) else { break };
// Only the first line's first word is known here. Later lines are placed as the composed
// lines need: each fits its width, so its first word does too.
let word = if first { word_width(sg, 0) } else { 0.0 };
let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent, word) else { break };
let w = x1 - x0 - ind_l - para.right_indent;
widths.push(w);
acc += w.max(1.0);
@@ -1492,6 +1538,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
queued: vec![],
model: t.para_at(0).leading_model,
next_top: None,
no_room_for: f64::INFINITY,
};
'paras: for (pi, pr) in paras.iter().enumerate() {
let para = t.para_at(pi);
@@ -1527,6 +1574,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let est = if i < n { Metrics::of(&sg[i]) } else { pm };
let first_line = li_para == 0;
let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 };
// Measured before Mojikumi narrows an opening bracket, as the composer measures it.
let word = word_width(&sg, i);
// Mojikumi: an opening bracket starting a line is set flush with the line's start (the
// space before it goes), and the line has that much more room. At a paragraph's start
// that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①).
@@ -1543,7 +1592,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
// next row/column if it no longer fits).
let (baseline, x0, x1, end, hyph, m) = loop {
let first_in_region = pen.prev.is_none();
let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, para.right_indent) else {
let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, para.right_indent, word) else {
cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start };
break 'paras;
};
+2 -1
View File
@@ -14,6 +14,7 @@ pub mod craft_fonts;
pub mod edit;
pub mod embed;
mod features;
mod fontconfig;
mod fontdb;
pub mod hyphen;
mod layout;
@@ -25,7 +26,7 @@ pub mod test_fonts;
pub mod thread;
pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont};
pub use features::{LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by};
pub use fontdb::{
FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, IcfMargins, PlatformFontFiles, WantedFont, WantedFonts, app_font_dir,
is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, style_weight, system_font_dirs, user_font_dirs,
+32
View File
@@ -212,6 +212,38 @@ fn area_flow_inside_circle_keeps_glyphs_inside() {
assert!(w(l.lines.len() / 2) > w(0));
}
/// In a frame that is narrow at the top, a word that doesn't fit the top lines moves down to the
/// first line wide enough for it (the lines above stay empty), with either composer. Only a word
/// wider than every line is broken.
#[test]
fn area_flow_moves_a_word_down_to_a_line_wide_enough_for_it() {
let triangle = |base: f64, height: f64| {
let mut p = BezPath::new();
p.move_to((base / 2.0, 0.0));
p.line_to((base, height));
p.line_to((0.0, height));
p.close_path();
p
};
let text = "Wonderful extraordinary typesetting";
for composer in [Composer::EveryLine, Composer::SingleLine] {
let mut t = area_path(text, style(12.0), &triangle(200.0, 200.0), Justify::Left);
t.para.composer = composer;
let l = layout(db(), &t);
assert!(!l.overflow, "{composer:?}");
let lines: Vec<&str> = l.lines.iter().map(|li| text[li.start..li.end].trim_end()).collect();
// The top lines are wide enough for "Wo" but not for "Wonderful": it starts lower.
assert_eq!(lines.first(), Some(&"Wonderful"), "{composer:?}: {lines:?}");
for li in &lines {
assert!(li.split(' ').all(|w| text.split(' ').any(|word| word == w)), "{composer:?}: {lines:?}");
}
}
// No line of this triangle is as wide as the word: it is still broken, and every letter is set.
let word = "Supercalifragilisticexpialidocious";
let l = layout(db(), &area_path(word, style(12.0), &triangle(60.0, 400.0), Justify::Left));
assert!(!l.overflow && l.lines.len() > 1, "{:?}", line_places(&l));
}
/// Lays `t` out as [`layout`] does, on a thread of its own, and fails the test when that panics or
/// takes more than 20 s.
fn layout_soon(t: &TextObject) -> TextLayout {
+97
View File
@@ -384,6 +384,12 @@ impl Targets {
self
}
/// The ruler guides alone ([`Self::with_rulers`]): what a dragged selection's grabbed point
/// still snaps to with View → Snap to Point when Smart Guides line the selection up.
pub(crate) fn rulers(cx: &ToolContext) -> Self {
Self::default().styled(cx).with_rulers(cx)
}
/// What a dragged selection's grabbed point snaps to with View → Snap to Point and Smart
/// Guides off: the anchors of the art but `exclude` and the ruler guides.
pub fn snap_to_point(cx: &ToolContext, exclude: &[NodeId]) -> Option<Self> {
@@ -434,6 +440,9 @@ impl Targets {
if let Some((q, k)) = self.nearest_point(p, tol) {
return (q, self.label(q, k.label()).into_iter().collect());
}
if let Some(q) = self.crossing(p, tol) {
return (q, self.label(q, "intersect").into_iter().collect());
}
if let Some(q) = self.on_segment(p, tol) {
return (q, self.label(q, Kind::Edge.label()).into_iter().collect());
}
@@ -539,6 +548,43 @@ impl Targets {
.map(|(c, n)| c.eval(n.t))
}
/// Where two target paths, a path and a ruler guide, or two ruler guides cross, the crossing
/// nearest `p` within `tol` ("intersect", #1602).
fn crossing(&self, p: Point, tol: f64) -> Option<Point> {
// A ruler guide running past, as a short line through the reach of `p`.
let ruler = |r: &Ruler| {
let (pos, ..) = r.line_at(p, tol)?;
let (across, along) = if r.vertical { (p.x, p.y) } else { (p.y, p.x) };
let (a, b) = (along - tol, along + tol);
((pos - across).abs() <= tol)
.then(|| PathSeg::Line(if r.vertical { Line::new((pos, a), (pos, b)) } else { Line::new((a, pos), (b, pos)) }))
};
let near: Vec<PathSeg> = self
.segments
.iter()
.filter(|(b, c)| b.inflate(tol, tol).contains(p) && c.nearest(p, 1e-6).distance_sq <= tol * tol)
.map(|(_, c)| *c)
.chain(self.rulers.iter().filter_map(ruler))
// shortcut: the first 32 lines in reach are paired, raise it if busier crossings matter
.take(32)
.collect();
let mut best: Option<Point> = None;
for (i, a) in near.iter().enumerate() {
for b in near.iter().skip(i + 1) {
let hits: Vec<Point> = match (a, b) {
(PathSeg::Line(l), c) | (c, PathSeg::Line(l)) => c.intersect_line(*l).iter().map(|x| l.eval(x.line_t)).collect(),
_ => meeting(a, b, p).into_iter().collect(),
};
for q in hits {
if q.distance(p) <= tol && best.is_none_or(|b| q.distance(p) < b.distance(p)) {
best = Some(q);
}
}
}
}
best
}
/// Snap a ruler guide at `v` (the x of a vertical one, the y of a horizontal one) into line
/// with the nearest edge, centre or anchor within `tol`: where it goes, and a label there.
pub fn snap_guide(&self, vertical: bool, v: f64, tol: f64) -> (f64, Vec<Overlay>) {
@@ -822,6 +868,20 @@ impl Targets {
}
}
/// Where curves `a` and `b` cross near `p`: a point projected from one onto the other and back
/// settles where they meet (none when they don't nearby).
fn meeting(a: &PathSeg, b: &PathSeg, p: Point) -> Option<Point> {
let mut q = p;
for _ in 0..32 {
let on_a = a.eval(a.nearest(q, 1e-9).t);
q = b.eval(b.nearest(on_a, 1e-9).t);
if on_a.distance(q) <= 1e-6 {
return Some(q);
}
}
None
}
/// Snap `p` for a drawing tool when smart guides (or grid snapping) are on.
pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<Overlay>) {
snap_with(cx, p, || Targets::collect(cx.doc, exclude, None))
@@ -1374,6 +1434,43 @@ mod tests {
assert_eq!(q, Point::new(340.0, 340.0));
}
/// #1602: a drawn point lands where two paths cross, where a path crosses a ruler guide and
/// where two ruler guides cross, labelled "intersect"; an anchor nearby still wins.
#[test]
fn drawn_points_land_on_intersections() {
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
// A slanted line through the rect's top side (y = 100) at x = 150, and a circle across its
// right side (x = 200).
for path in [shapes::line(Point::new(145.0, 90.0), Point::new(165.0, 130.0)), shapes::ellipse(Rect::new(175.0, 130.0, 215.0, 170.0))] {
let id = d.alloc_id();
d.insert(Some(l), 1, Node::path(id, path, Appearance::default_art())).unwrap();
}
d.guides.push(vectorcraft_doc::Guide::new(true, 400.0));
d.guides.push(vectorcraft_doc::Guide::new(false, 300.0));
d.guides.push(vectorcraft_doc::Guide::new(false, 150.0));
let (s, p) = (Selection::default(), paint());
let c = ToolContext { guides: true, ..cx(&d, &s, &p) };
let t = Targets::for_drawing(&d, &[], None).styled(&c).with_rulers(&c);
let (q, ov) = t.snap_from(Point::new(152.0, 101.5), None, 4.0);
assert!((q - Point::new(150.0, 100.0)).hypot() < 1e-9, "{q:?}");
assert_eq!(labels(&ov), ["intersect"]);
// The circle (curves, centre (195, 150), radius 20) crosses the rect's right side.
let (q, ov) = t.snap_from(Point::new(201.5, 131.5), None, 4.0);
assert!((q - Point::new(200.0, 150.0 - 375f64.sqrt())).hypot() < 0.02, "{q:?}");
assert_eq!(labels(&ov), ["intersect"]);
// A ruler guide across a path, and two ruler guides.
let (q, ov) = t.snap_from(Point::new(101.0, 151.0), None, 4.0);
assert_eq!((q, labels(&ov)), (Point::new(100.0, 150.0), vec!["intersect"]));
let (q, ov) = t.snap_from(Point::new(402.0, 298.0), None, 4.0);
assert_eq!((q, labels(&ov)), (Point::new(400.0, 300.0), vec!["intersect"]));
// An anchor in reach wins, and a path alone is still "path".
assert_eq!(labels(&t.snap_from(Point::new(146.0, 91.0), None, 4.0).1), ["anchor"]);
assert_eq!(labels(&t.snap_from(Point::new(170.0, 201.0), None, 4.0).1), ["path"]);
}
/// The angle sets of Preferences › Smart Guides › Construction Guides.
#[test]
fn construction_angle_sets() {
+35
View File
@@ -90,6 +90,9 @@ pub(crate) struct MoveSnap {
/// original.
copy_targets: Option<Targets>,
copy_points: Option<Targets>,
/// With Smart Guides on, the ruler guides View › Snap to Point still pulls the grabbed point
/// onto (#1534); `points` holds them with Smart Guides off.
rulers: Option<Targets>,
}
impl MoveSnap {
@@ -102,6 +105,7 @@ impl MoveSnap {
points: points(&cx.selection.objects),
copy_targets: guides.then(|| Targets::for_move(cx, &[])),
copy_points: points(&[]),
rulers: (guides && cx.snap_to_point).then(|| Targets::rulers(cx)),
}
}
@@ -133,6 +137,14 @@ impl MoveSnap {
guides = ov;
}
}
// A ruler guide within reach of the grabbed point wins over lining the bounds up.
if let Some(t) = &self.rulers {
let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance));
if q != start + d {
d = q - start;
guides = ov;
}
}
if cx.snap_to_pixel
&& let Some(b) = self.bounds
{
@@ -733,6 +745,29 @@ mod tests {
assert_eq!(a, vec![Action::Commit]);
}
/// #1534: with Smart Guides on (the default), the point a selection is dragged by still snaps
/// to a ruler guide within the Snap to Point distance; not with Snap to Point off.
#[test]
fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() {
let (mut d, id) = doc_with_rect();
d.guides = vec![vectorcraft_doc::Guide::new(true, 173.4)];
let mut s = Selection::default();
s.add(id);
let p = paint();
let dx = |c: &ToolContext, to: f64| {
let mut t = SelectionTool::default();
t.pointer(c, &ev(PointerKind::Down, 150.0, 150.0));
let a = t.pointer(c, &ev(PointerKind::Drag, to, 150.0));
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
v["matrix"][4].as_f64().unwrap()
};
let c = cx(&d, &s, &p);
assert!(c.smart_guides && c.snap_to_point);
assert!((dx(&c, 172.0) - 23.4).abs() < 1e-9, "onto the guide");
assert_eq!(dx(&c, 165.0), 15.0, "out of reach");
assert_eq!(dx(&ToolContext { snap_to_point: false, ..cx(&d, &s, &p) }, 172.0), 22.0);
}
/// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as
/// gathered before the previews moved it); released, the move doesn't.
#[test]
-1
View File
@@ -10,7 +10,6 @@ workspace = true
[dependencies]
vectorcraft-geom = { workspace = true }
vectorcraft-pathops = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
serde = { workspace = true }
+183 -8
View File
@@ -12,14 +12,23 @@
//! merged into one point) become polylines through the pixel centres; rings without ends
//! become closed ones. Short spurs that thinning leaves at junctions are pruned and the runs
//! on either side of a junction left with two of them are joined.
//! 5. **Curves**: each polyline is fitted like an outline ([`fit::fit_polyline`]); the stroke
//! width is the line's mean width (its pixel count over its length).
//! 5. **Ink**: the skeleton is right to a pixel only, so each of its points is moved to the middle
//! of the line's ink, read from the image: across the line (along the pixel column or row most
//! nearly square to it) each pixel is as much ink as its colour is the line's rather than the
//! paper's, and the ink's centroid is the line's middle there and its sum the line's width (an
//! anti-aliased or blurred line says both to a fraction of a pixel). The stroke width is the
//! median of those widths, and each end is put where the ink ends, less the half width a round
//! cap adds.
//! 6. **Curves**: each polyline is fitted as outlines are ([`fit::fit_piece`]): a straight line
//! is two anchors, corners stay corners, the rest is as few curves as Paths allows.
use std::collections::HashSet;
use vectorcraft_geom::{PathData, Point};
use crate::fit::{self, FitOptions};
use crate::Raster;
use crate::fit;
use crate::logo::Style;
/// One line of a colour layer: its centre lines and the width to stroke them with.
pub(crate) struct Line {
@@ -31,7 +40,19 @@ pub(crate) struct Line {
/// The lines of colour `label` in `labels` (`w` × `h`, row-major) no wider than `max_width`
/// pixels, and which pixels they cover (to leave out of the filled areas).
pub(crate) fn lines(labels: &[u16], label: u16, w: usize, h: usize, max_width: f64, o: &FitOptions) -> (Vec<bool>, Vec<Line>) {
/// `ink` is the colour of the layer's ink in `img` (its palette colour; in Black and White the
/// image's own black), which the lines' middles and widths are read against.
#[allow(clippy::too_many_arguments)]
pub(crate) fn lines(
labels: &[u16],
label: u16,
w: usize,
h: usize,
max_width: f64,
img: &Raster,
ink: [f64; 3],
style: &Style,
) -> (Vec<bool>, Vec<Line>) {
let mut thin = vec![false; labels.len()];
let mut out = vec![];
if w == 0 || h == 0 || labels.len() != w * h {
@@ -61,7 +82,7 @@ pub(crate) fn lines(labels: &[u16], label: u16, w: usize, h: usize, max_width: f
if widest > max_width {
continue;
}
let Some(line) = trace_line(&comp, w, max_width, o) else { continue };
let Some(line) = trace_line(&comp, w, max_width, img, ink, style) else { continue };
for &i in &comp {
thin[i] = true;
}
@@ -221,7 +242,7 @@ impl Run {
}
/// The centre lines of one line component (pixel indices of the `w`-wide image).
fn trace_line(comp: &[usize], w: usize, max_width: f64, o: &FitOptions) -> Option<Line> {
fn trace_line(comp: &[usize], w: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
let xs = comp.iter().map(|&i| i % w);
let ys = comp.iter().map(|&i| i / w);
let (x0, x1, y0, y1) = (xs.clone().min()?, xs.max()?, ys.clone().min()?, ys.max()?);
@@ -312,14 +333,168 @@ fn trace_line(comp: &[usize], w: usize, max_width: f64, o: &FitOptions) -> Optio
};
let spur = width(&runs);
let runs = join(prune(runs, centres.len(), spur), centres.len());
let width = width(&runs);
let subs: Vec<_> = runs.iter().filter_map(|r| fit::fit_polyline(&r.pts, r.closed, o)).collect();
let mut runs = runs;
let coarse = width(&runs);
let ink = Ink { img, color: ink, reach: (max_width / 2.0).ceil().clamp(1.0, 64.0) as i64 + 2 };
// How many runs end at each node: one is an end of the line.
let mut meet = vec![0usize; centres.len()];
for e in runs.iter().flat_map(|r| r.ends.iter().flatten()) {
meet[*e] += 1;
}
let (mut widths, mut soft, mut read) = (vec![], 0usize, 0usize);
for r in &mut runs {
let n = r.pts.len();
let old = r.pts.clone();
for i in 0..n {
// A junction stays where its pixels put it.
let end = if i == 0 {
r.ends[0]
} else if i + 1 == n {
r.ends[1]
} else {
None
};
if end.is_some_and(|e| meet[e] > 1) {
continue;
}
let (a, b) = if r.closed { (old[(i + n - 2 % n) % n], old[(i + 2) % n]) } else { (old[i.saturating_sub(2)], old[(i + 2).min(n - 1)]) };
let d = b - a;
if d.hypot() < 1e-9 {
continue;
}
let upright = d.x.abs() >= d.y.abs();
let Some(across) = ink.across(old[i], upright) else { continue };
r.pts[i] = old[i] + if upright { kurbo::Vec2::new(0.0, across.middle) } else { kurbo::Vec2::new(across.middle, 0.0) };
widths.push(across.width * if upright { d.x.abs() } else { d.y.abs() } / d.hypot());
read += 1;
soft += usize::from(across.soft);
}
}
// The stroke is as wide as the ink at the line's middle point (where no ink could be read, as
// wide as its pixels make it).
widths.sort_by(f64::total_cmp);
let width = widths.get(widths.len() / 2).copied().unwrap_or(coarse).clamp(0.25, max_width.max(1.0));
// Each end goes where the ink ends, less the half width its round cap adds.
for r in runs.iter_mut().filter(|r| !r.closed && r.pts.len() >= 2) {
for k in 0..2 {
if r.ends[k].is_some_and(|e| meet[e] > 1) {
continue;
}
let n = r.pts.len();
let (tip, inner) = if k == 0 { (r.pts[0], r.pts[3.min(n - 1)]) } else { (r.pts[n - 1], r.pts[n - 1 - 3.min(n - 1)]) };
let d = tip - inner;
if d.hypot() < 1e-9 {
continue;
}
let more = ink.beyond(tip, d / d.hypot()) - width / 2.0;
if more.abs() > 0.05 && more > -1.0 {
let to = tip + d / d.hypot() * more;
if k == 0 { r.pts[0] = to } else { r.pts[n - 1] = to }
}
}
}
let mut style = *style;
if read > 0 && soft * 4 < read {
// Hard pixels: the middle is only known to about half a pixel (as for outlines).
style.tol *= 2.5;
style.line_tol *= 2.5;
style.sigma = 1.5;
}
let subs: Vec<_> = runs
.iter()
.filter_map(|r| {
if r.closed { fit::subpath(&fit::fit_closed(&r.pts, &style)?, true) } else { fit::subpath(&fit::fit_piece(&r.pts, &style, false), false) }
})
.collect();
if subs.is_empty() {
return None;
}
Some(Line { path: PathData::new(subs), width, pixels: comp.len() })
}
/// What the image says of a line's ink.
struct Ink<'a> {
img: &'a Raster,
/// The line's colour.
color: [f64; 3],
/// How far (pixels) from the skeleton the ink is looked for.
reach: i64,
}
/// The ink across a line at one point: where its middle lies from the point, how wide it is, and
/// whether its edges are soft (some pixel is part ink, part paper).
struct Across {
middle: f64,
width: f64,
soft: bool,
}
impl Ink<'_> {
/// How far the colour of pixel `(x, y)` is from the line's (`None` off the image).
fn far(&self, x: i64, y: i64) -> Option<f64> {
let (w, h) = (i64::from(self.img.width), i64::from(self.img.height));
if x < 0 || y < 0 || x >= w || y >= h {
return None;
}
let at = (y * w + x) as usize * 4;
let p = self.img.rgba.get(at..at + 3)?;
Some((0..3).map(|k| (f64::from(p[k]) - self.color[k]).powi(2)).sum::<f64>().sqrt())
}
/// How far the paper's colour is from the line's around pixel `(x, y)`: the farthest colour
/// within reach, along its row and its column.
fn paper(&self, x: i64, y: i64) -> f64 {
(-self.reach..=self.reach).flat_map(|k| [self.far(x + k, y), self.far(x, y + k)]).flatten().fold(0.0, f64::max)
}
/// The ink across the line through the pixel `p` lies in: along its column (`upright`) or row.
fn across(&self, p: Point, upright: bool) -> Option<Across> {
let (x, y) = (p.x.floor() as i64, p.y.floor() as i64);
let paper = self.paper(x, y);
// Paper too like the ink says nothing.
if paper < 24.0 {
return None;
}
let tone = |k: i64| (if upright { self.far(x, y + k) } else { self.far(x + k, y) }).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
// From the pixel with most ink near the skeleton's (thinning leaves it up to a pixel or two
// off the line's darkest row) out to where the ink ends, or rises again (another line).
let peak = [0, -1, 1, -2, 2].into_iter().fold(0, |best, k| if tone(k) > tone(best) + 1e-9 { k } else { best });
let here = tone(peak);
let (mut sum, mut moment, mut soft) = (here, here * peak as f64, here > 0.1 && here < 0.9);
for side in [-1, 1] {
let mut last = here;
for k in 1..=self.reach {
let t = tone(peak + side * k);
if t < 0.04 || t > last + 0.1 {
break;
}
sum += t;
moment += t * (peak + side * k) as f64;
soft |= t > 0.1 && t < 0.9;
last = t;
}
}
// The pixel's centre is at `p`'s own half-pixel; the middle is measured from `p`.
let centre = if upright { y as f64 + 0.5 - p.y } else { x as f64 + 0.5 - p.x };
(sum > 0.0).then(|| Across { middle: centre + moment / sum, width: sum, soft })
}
/// How much ink (pixels) lies beyond `tip`, going `dir` (a unit vector).
fn beyond(&self, tip: Point, dir: kurbo::Vec2) -> f64 {
let paper = self.paper(tip.x.floor() as i64, tip.y.floor() as i64);
if paper < 24.0 {
return 0.0;
}
let mut sum = 0.0;
for step in 1..=4 * (self.reach + 2) {
let q = tip + dir * (step as f64 * 0.25);
let t = self.far(q.x.floor() as i64, q.y.floor() as i64).map_or(0.0, |d| (1.0 - d / paper).clamp(0.0, 1.0));
if t < 0.04 {
break;
}
sum += 0.25 * t;
}
sum
}
}
/// The runs without the spurs thinning leaves: runs shorter than the line's `width` from a line end
/// to a junction that keeps at least two other runs.
fn prune(runs: Vec<Run>, nodes: usize, width: f64) -> Vec<Run> {
+5 -3
View File
@@ -51,7 +51,8 @@ fn step(d: u8) -> (i32, i32) {
fn label(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
let mut comp = vec![u32::MAX; w * h];
let mut sizes = Vec::new();
let mut stack = Vec::new();
// (Indices as `u32`: a mask has at most 64 million pixels, and this stack can hold them all.)
let mut stack: Vec<u32> = Vec::new();
for s in 0..w * h {
if !mask[s] || comp[s] != u32::MAX {
continue;
@@ -59,14 +60,15 @@ fn label(mask: &[bool], w: usize, h: usize) -> (Vec<u32>, Vec<usize>) {
let id = sizes.len() as u32;
let mut n = 0usize;
comp[s] = id;
stack.push(s);
stack.push(s as u32);
while let Some(i) = stack.pop() {
let i = i as usize;
n += 1;
let (x, y) = (i % w, i / w);
let mut go = |j: usize| {
if mask[j] && comp[j] == u32::MAX {
comp[j] = id;
stack.push(j);
stack.push(j as u32);
}
};
if x > 0 {
+96 -87
View File
@@ -1,20 +1,11 @@
//! Loop → Bézier curves, and centre line → Bézier curves.
//! Loop → Bézier curves, and the pieces of that which centre lines use too.
use vectorcraft_geom::{Anchor, AnchorKind, CubicBez, ParamCurve, PathData, Point, SubPath};
use vectorcraft_pathops::{SimplifyOptions, simplify_with};
use vectorcraft_geom::{Anchor, AnchorKind, Point, SubPath};
use crate::contour::Loop;
use crate::logo::{Cubic, Stats, Style, all_within, fit_open, fit_outline, resample_closed, resample_open, sample_cubics};
use crate::subpixel::{self, Crack};
#[derive(Clone, Copy, Debug)]
pub(crate) struct FitOptions {
pub polygon_tol: f64,
pub fit_tol: f64,
pub corner_angle: f64,
pub snap_lines: bool,
}
fn seg_dist(p: Point, a: Point, b: Point) -> f64 {
let ab = b - a;
let l2 = ab.hypot2();
@@ -60,10 +51,78 @@ fn before(a: Point, b: Point) -> std::cmp::Ordering {
a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))
}
/// Lines within this of level or upright are made exactly so (the tangent of 2.5°).
const NEARLY_UPRIGHT: f64 = 0.0437;
/// The farthest (px) levelling a line may move either of its ends.
const LEVEL_REACH: f64 = 1.6;
/// Snap Curves To Lines: the straight pieces of `segs` (a chain; `closed` when its end joins its
/// start) that are within 2.5° of level or upright and at least 2 px long are made exactly level
/// or upright (unless that moves an end by more than [`LEVEL_REACH`]), by moving their ends
/// across them: both to their mean, or, in an open chain, the
/// free end to the end that is fixed (with `fixed_ends`, a chain's own ends stay where they are:
/// they are junctions other regions share). The pieces next to a moved end follow it.
fn snap_upright(segs: &mut [Cubic], closed: bool, fixed_ends: bool) {
let n = segs.len();
let straight = |c: &Cubic| seg_dist(c[1], c[0], c[3]) < 1e-6 && seg_dist(c[2], c[0], c[3]) < 1e-6;
let lines: Vec<bool> = segs.iter().map(straight).collect();
// Anchor `i` starts piece `i`; an open chain has one more, at its end.
let fixed = |i: usize| !closed && fixed_ends && (i == 0 || i == n);
let at = |segs: &[Cubic], i: usize| if i < n { segs[i][0] } else { segs[n - 1][3] };
let put = |segs: &mut [Cubic], i: usize, p: Point| {
let i = if closed { i % n } else { i };
let d = p - at(segs, i);
if i < n {
segs[i][0] = p;
segs[i][1] += d;
}
if let Some(before) = if i > 0 { Some(i - 1) } else { closed.then_some(n - 1) } {
segs[before][3] = p;
segs[before][2] += d;
}
};
for (i, &line) in lines.iter().enumerate() {
let (ia, ib) = (i, if closed { (i + 1) % n } else { i + 1 });
let (a, b) = (at(segs, ia), at(segs, ib));
let d = b - a;
if !line || d.hypot() < 2.0 || ia == ib {
continue;
}
let level = d.y.abs() <= NEARLY_UPRIGHT * d.x.abs();
if !level && d.x.abs() > NEARLY_UPRIGHT * d.y.abs() {
continue;
}
let pick = |p: Point| if level { p.y } else { p.x };
let to = match (fixed(ia), fixed(ib)) {
(true, true) => continue,
(true, false) => pick(a),
(false, true) => pick(b),
(false, false) => (pick(a) + pick(b)) / 2.0,
};
// Not when an end would go farther than a pixel and a half or so from where the image has it: a long line a
// degree or two off is that line, not a level one drawn badly.
if (pick(a) - to).abs().max((pick(b) - to).abs()) > LEVEL_REACH {
continue;
}
let moved = |p: Point| if level { Point::new(p.x, to) } else { Point::new(to, p.y) };
if !fixed(ia) {
put(segs, ia, moved(a));
}
if !fixed(ib) {
put(segs, ib, moved(b));
}
}
// A straight piece has its handles on it.
for (c, _) in segs.iter_mut().zip(&lines).filter(|(_, line)| **line) {
*c = [c[0], c[0] + (c[3] - c[0]) / 3.0, c[0] + (c[3] - c[0]) * (2.0 / 3.0), c[3]];
}
}
/// A whole loop fitted. The same loop gives the same curves whichever way it runs and wherever it
/// starts (it is fitted clockwise from its leftmost point): the outline of a shape and the hole it
/// leaves in the shape around it are one curve.
fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
pub(crate) fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
let n = pts.len();
let area2: f64 = (0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum();
let flip = area2 < 0.0;
@@ -80,13 +139,17 @@ fn fit_closed(pts: &[Point], style: &Style) -> Option<Vec<Cubic>> {
if stats.reverted > 0 && !all_within(&sample_cubics(&segs, 0.25), &resample_closed(&pts, 0.5).map_or_else(|| pts.clone(), |(p, _)| p), 1.5) {
segs = polygon(&pts, style.tol);
}
if style.upright {
snap_upright(&mut segs, true, false);
}
Some(if flip { reversed(segs) } else { segs })
}
/// A piece of a loop between two junctions fitted, from its first point to its last. The same
/// piece gives the same curve whichever way it runs (it is fitted from its end further left): the
/// edge two regions share is one curve.
fn fit_piece(pts: &[Point], style: &Style) -> Vec<Cubic> {
/// edge two regions share is one curve. `fixed_ends`: its ends are junctions and stay put (a centre
/// line's ends are its own).
pub(crate) fn fit_piece(pts: &[Point], style: &Style, fixed_ends: bool) -> Vec<Cubic> {
let n = pts.len();
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return vec![] };
let flip = match before(first, last) {
@@ -107,6 +170,9 @@ fn fit_piece(pts: &[Point], style: &Style) -> Vec<Cubic> {
let kept: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
segs = kept.windows(2).filter(|p| p[0] != p[1]).map(|p| [p[0], p[0], p[1], p[1]]).collect();
}
if style.upright {
snap_upright(&mut segs, false, fixed_ends);
}
if flip { reversed(segs) } else { segs }
}
@@ -137,22 +203,30 @@ pub(crate) fn fit_loop(
if piece.closed {
segs = fit_closed(&piece.pts, &style)?;
} else {
segs.extend(fit_piece(&piece.pts, &style));
segs.extend(fit_piece(&piece.pts, &style, true));
}
}
subpath(&segs, true)
}
/// A chain of cubics as a subpath: closed (its end joins its start) or open. A straight piece has
/// no handles; an anchor whose two handles line up is smooth.
pub(crate) fn subpath(segs: &[Cubic], closed: bool) -> Option<SubPath> {
let n = segs.len();
// A straight piece has no handles.
let straight = |c: &Cubic| seg_dist(c[1], c[0], c[3]) < 1e-6 && seg_dist(c[2], c[0], c[3]) < 1e-6;
let mut anchors = Vec::with_capacity(n);
for (i, cur) in segs.iter().enumerate() {
let before = segs.get((i + n - 1) % n)?;
let p = cur[0];
let (h_in, h_out) = (if straight(before) { p } else { before[2] }, if straight(cur) { p } else { cur[1] });
let mut anchors = Vec::with_capacity(n + 1);
for i in 0..if closed { n } else { n + 1 } {
// The piece that arrives at this anchor and the one that leaves it.
let before = if i > 0 { segs.get(i - 1) } else { closed.then(|| segs.last()).flatten() };
let after = segs.get(i);
let p = after.map(|c| c[0]).or(before.map(|c| c[3]))?;
let h_in = before.filter(|c| !straight(c)).map_or(p, |c| c[2]);
let h_out = after.filter(|c| !straight(c)).map_or(p, |c| c[1]);
let (vi, vo) = (p - h_in, h_out - p);
let smooth = vi.hypot() > 1e-9 && vo.hypot() > 1e-9 && vi.cross(vo).abs() < 0.07 * vi.hypot() * vo.hypot() && vi.dot(vo) > 0.0;
anchors.push(Anchor { p, h_in, h_out, kind: if smooth { AnchorKind::Smooth } else { AnchorKind::Corner } });
}
(n >= 2).then(|| SubPath::new(anchors, true))
(n >= if closed { 2 } else { 1 }).then(|| SubPath::new(anchors, closed))
}
/// The closed outline `pts` as straight pieces within `tol` of it (Douglas–Peucker).
@@ -170,68 +244,3 @@ fn polygon(pts: &[Point], tol: f64) -> Vec<Cubic> {
let kept: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
(0..kept.len()).map(|i| [kept[i], kept[i], kept[(i + 1) % kept.len()], kept[(i + 1) % kept.len()]]).collect()
}
/// Fit a centre line through `pts` (pixel-centre points in order) into a Bézier subpath: the
/// points within `polygon_tol` of it are dropped (Douglas–Peucker), then curves are fitted. A
/// `closed` line runs back to its first point.
pub(crate) fn fit_polyline(pts: &[Point], closed: bool, o: &FitOptions) -> Option<SubPath> {
let n = pts.len();
let first = *pts.first()?;
if n < 2 {
return None;
}
let mut ext = pts.to_vec();
let mut keep = vec![false; n + 1];
keep[0] = true;
if closed {
// Split the ring at its first point and the point farthest from it.
ext.push(first);
let far = (1..n).max_by(|&i, &j| pts[i].distance_squared(first).total_cmp(&pts[j].distance_squared(first))).unwrap_or(n / 2);
keep[far] = true;
rdp(&ext, 0, far, o.polygon_tol, &mut keep);
rdp(&ext, far, n, o.polygon_tol, &mut keep);
} else {
keep[n - 1] = true;
rdp(&ext, 0, n - 1, o.polygon_tol, &mut keep);
}
let mut poly: Vec<Point> = (0..n).filter(|&i| keep[i]).map(|i| pts[i]).collect();
if closed && poly.len() < 3 {
poly = pts.to_vec();
}
if poly.len() < if closed { 3 } else { 2 } {
return None;
}
let sp = SubPath::polyline(&poly, closed);
let fitted =
simplify_with(&PathData::single(sp), &SimplifyOptions { tolerance: o.fit_tol, corner_angle_deg: o.corner_angle, straight_lines: false });
let mut sp = fitted.subpaths.into_iter().next()?;
if sp.anchors.len() < 2 {
return None;
}
if o.snap_lines {
snap_to_lines(&mut sp, (o.fit_tol * 2.0).max(1.5));
}
Some(sp)
}
/// Retract the handles of curves that stay within `tol` of their chord.
fn snap_to_lines(sp: &mut SubPath, tol: f64) {
let n = sp.anchors.len();
for i in 0..sp.segment_count() {
let j = (i + 1) % n;
let (a, b) = (sp.anchors[i], sp.anchors[j]);
if !a.has_out() && !b.has_in() {
continue;
}
let c = CubicBez::new(a.p, a.h_out, b.h_in, b.p);
if (1..8).all(|k| seg_dist(c.eval(k as f64 / 8.0), a.p, b.p) <= tol) {
sp.anchors[i].h_out = a.p;
sp.anchors[j].h_in = b.p;
}
}
for a in &mut sp.anchors {
if !a.has_in() || !a.has_out() {
a.kind = AnchorKind::Corner;
}
}
}
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more