Compare commits

...
Author SHA1 Message Date
BFlat 4b7969a05c Merge pull request #3685 from ham9zah/fix/ai-closed-path-type-range
Import: type on a closed path keeps its wrapped end bracket
2026-10-23 02:07:07 +07:00
BFlat f7dde42990 Merge pull request #3634 from makesomethingshit/fix/3607-save-for-web-active-artboard
Save for Web: initialize from the active artboard
2026-10-23 02:06:49 +07:00
BFlat ecbcf92f3a Merge pull request #3665 from dotCooCoo/feat/shape-builder-stroke-split
Shape Builder: in merge mode, a click on a stroke splits the path there
2026-10-23 02:06:32 +07:00
BFlat 44e1c3af62 Merge pull request #3684 from Taha-Mahmoodi/feat/gradient-perceptual
Gradients: Method › Perceptual mixes a gradient's colours in OKLab; SVG and PDF write it as an image
2026-10-23 02:06:14 +07:00
ham9zah 1abc5d4066 Import: type on a closed path keeps its wrapped end bracket
Round a closed path the editing data's text document writes the end of
the type's range unwrapped, past the last segment (the start plus the
run), or wrapped the other way. The importer clamped it to the path's
end, so the type kept only the stretch from its start bracket to the
path's end and overflowed. Both brackets are now wrapped onto the loop
(non-finite values fall back to the start); an open path is clamped as
before.

The object's own path that then replaces the frame is written open,
ending on its start: such a path is now closed where its ends meet, so
the span is measured round the loop.

Hand-written tests: an unwrapped, a reversed and a negative range on a
closed circle, a range inside it, an exact full turn that fits a short
line, an open path still clamped, and a path ending on its start closed.
2026-10-23 01:53:47 +07:00
makesomethingshit 6a394c3a6f #3607: follow the active artboard with every tool 2026-10-23 01:53:47 +07:00
makesomethingshit 4d9749f4c7 #3607: open Save for Web on the active artboard 2026-10-23 01:53:35 +07:00
bflatastic efe2429618 Shape Builder: in merge mode, a click on a stroke splits the path there
Squashed from #3665 onto main.
2026-10-23 01:53:34 +07:00
Taha-Mahmoodi 8924b24e94 Gradients: Expand to a gradient mesh keeps a Perceptual gradient's colour curve
Review follow-up (CodeRabbit): a linear gradient expands to a mesh with a
column at each stop, and the mesh mixes linearly between columns, so a
Perceptual blue -> yellow came out as the grey RGB mix in the middle.
`breaks` now also takes a column at each stop the Perceptual expansion adds
(Gradient::expanded), so the mesh follows the OKLab curve; Classic is
unchanged. the_mesh_option_makes_a_mesh_that_paints_the_gradient gains a
Perceptual case; without the change 1812 pixels differ from the gradient.
2026-10-23 01:53:30 +07:00
Taha-Mahmoodi 6fd1f566e1 Gradients: the Perceptual test describes the method in our own words
Review follow-up (CodeRabbit): the test's doc comment quoted a phrase from
the public help page. AGENTS.md allows feature names and a cited URL, not
copied wording; the comment now describes the behaviour itself and keeps
the URL.
2026-10-23 01:53:30 +07:00
Taha-Mahmoodi 6beb68f934 Gradients: the Gradient panel's Method menu and paint.editGradient's method choose Classic or Perceptual
Illustrator 30.0's Method choice in the Gradient panel
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html).

- paint.editGradient takes `method: classic|perceptual` (one undo step);
  paint.setFill/setStroke take it in `gradient`, and gradient params (swatches,
  copied paints) carry it when Perceptual. Anything else is refused without
  editing. One edit can set `method` and `dither` together.
- The Gradient panel shows a Method menu (Classic, Perceptual) for linear and
  radial gradients, dimmed without a gradient; the Dither box stays under the
  stops.
- i18n: "Perceptual" where a complete catalog lacked it, and the `method`
  error in all 24 catalogs.
- Docs: G13 (estimate 14–24 h → 13–23 h, after Dither's 15–25 h → 14–24 h),
  target-app-parity (row and evidence), ui-parity, mcp.md, plugins.md.
2026-10-23 01:53:30 +07:00
Taha-Mahmoodi 2bad646821 Gradients: a Perceptual gradient mixes its colours in OKLab, and SVG and PDF write it as an image
Illustrator 30.0's gradient Method, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html):
Classic or Perceptual; perceptual interpolation matches colour transitions to
how the eye perceives differences between colours, and perceptual gradients
are rasterised when saved as PDF or SVG.

- Gradient gains `method` (Classic | Perceptual; serde default Classic,
  written only when Perceptual, so older files open as Classic and Classic
  files are written as before). The colour crate's OKLab helpers open up for it.
- Perceptual mixes neighbouring stop colours in OKLab (sample, and
  sample_keep_model for the stroke meshes, Expand and blends); opacity stays
  linear. Renderers that mix linearly get stops on the perceptual curve,
  placed by halving each half segment where a straight line strays more than
  0.4 of a colour level (a nearly straight segment needs none). Classic
  gradients expand exactly as before.
- draws_as_pixels includes Perceptual gradients, so SVG and PDF write a
  Perceptual fill or stroke as an image at the document's raster effects
  resolution, clipped to what it paints. A gradient along or across a stroke
  stays slices (PDF no longer draws it as a within-stroke image).
- With Dither: a dithered Perceptual fill is the perceptual mix with dither's
  noise (the canvas's dithered grids are keyed by method too); SVG and PDF
  write it as an image with the noise, and a dithered Perceptual stroke as an
  image without it, as the canvas draws it (GradientPaint::pixels, SVG's
  raster_paint and PDF's area are told whether they paint a fill).
2026-10-23 01:53:29 +07:00
BFlat e195fc94e2 Merge pull request #3676 from odgrim/fix/3562-svg-nested-image-depth
SVG import: images in images share the depth cap, SVGZ images one unpacking cap (#3562, #3563)
2026-10-23 01:42:22 +07:00
bflatastic 997dd8d2bd SVG import: images in images share the depth cap, SVGZ images one unpacking cap (#3562, #3563)
Squashed from #3676 onto main.
2026-10-23 01:28:52 +07:00
BFlat 4ee7173f11 Merge pull request #3683 from Silvertree2010/fix/h1324-color-to-hsb-returns-hue-360-for
Wrap to_hsb hue of 360 back to 0 for near-red colours
2026-10-23 01:23:57 +07:00
BFlat 3d9656024e Merge pull request #3682 from Taha-Mahmoodi/fix/dither-stroke-null-swatch
Gradients: paint.setStroke refuses a dithered gradient when "swatch" is null
2026-10-23 01:23:40 +07:00
BFlat 5b0e60f865 Merge pull request #3677 from addibble/perf/svg-import-single-pass
SVG import: read the text once for the prepass's markers, with the data URLs left out
2026-10-23 01:23:21 +07:00
BFlat 03bf93c203 Merge pull request #3681 from dotCooCoo/fix/package-skips-page-parsing-without-fonts
Package: reads placed pages and document previews only for the options that use them
2026-10-23 01:23:02 +07:00
BFlat 165fea7f9b Merge pull request #3667 from MiQ71/fix/3646-display-doc-title
Fix #3646: display PDF document title
2026-10-23 01:22:45 +07:00
NetterSilverandClaude Haiku 5.5 4a29fd4112 Wrap to_hsb hue of 360 back to 0 for near-red colours
Found while testing crates/color/src/lib.rs:142

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 01:06:55 +07:00
Taha-Mahmoodi e20c330bd3 Gradients: paint.setStroke refuses a dithered gradient when "swatch" is null
Review follow-up (CodeRabbit): the refusal skipped any request carrying a
`swatch` key, so `"swatch": null` with a dithered gradient got through.
Only a named swatch (a gradient swatch keeps its own dither) is exempt now.
The dither test sends `"swatch": null`; it fails without the change.
2026-10-23 01:01:28 +07:00
Drew DibbleandClaude Opus 5.5 a7ee168efe perf --deep: the embedded-picture import row reports a failed import instead of timing it
One import is checked first and its document kept through the memory reading after the row, so
a failure stops the run with its error rather than being timed as an import (CodeRabbit). The
row's comment now gives the picture's real size, about 2 MB as a PNG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:01:27 +07:00
Drew DibbleandClaude Fable 5.1 5b919c0529 SVG import: read the text once for the prepass's markers, with the data URLs left out
The prepass decided what to look for in the tree by a dozen substring searches over the whole
SVG text (`:not(`, the event attributes, `script`, `transform:` and nine more), and a separate
pass counted the elements' depth before roxmltree. With an embedded picture the text is mostly
its data URL: a 1.35 MB card SVG (a 968 KB PNG) paid about 7 ms of searches and 1 ms of depth
scan per import, on a 64 ms render, and a 54-page PDF export 54 times that.

`depth::prescan` is the one pass: it counts the depth as `xml_too_deep` did (the DOCTYPE entity
count included) and, in the same walk, notes every `data:` attribute value, answering the text
with those values left out, their quotes kept. The prepass and `fx::blends` search that text;
the edits are still made in the source. A data URL can hold no tag, attribute name, selector or
CSS declaration, and everything else, a `style="display:none"` included, is kept, so what is
found is what was found before.

`vectorcraft-cli perf --deep` gains a tracked row, "import SVG, one embedded PNG", beside the
export row it had.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-23 01:01:27 +07:00
dotCooCoo 48792842ec Package: reads placed pages and document previews only for the options that use them
With Copy Links on and Copy Fonts off, File › Package still read every
placed PDF or .ai page to find the fonts of its type, and then dropped
them. With Copy Links on and Relink off, it still read the preview and
the PDF of each placed .vectorcraft document, which only the rewritten
copy uses.

Package now reads the placed pages for their fonts only with Copy Fonts
on, and it reads the preview and the PDF only with Relink on. With Copy
Fonts off, packaging a document that places the 60 pages of a PDF took
about 390 ms in a release build and now takes about 20 ms. The package
it writes is the same.
2026-10-23 01:01:26 +07:00
Mirko Kerezovic 97c173beb9 Handle PDF title edge cases 2026-10-23 01:01:26 +07:00
Mirko Kerezovic d548b4ff74 Fix PDF title display provenance 2026-10-23 01:01:25 +07:00
Mirko Kerezovic 5e9df83dd4 Fix #3646: display PDF document title 2026-10-23 01:01:24 +07:00
BFlat 524ecee6ca Merge pull request #3561 from lehni/feature/emoji-bitmaps
Type: colour emoji fonts with bitmap glyphs draw and export
2026-10-23 00:59:15 +07:00
BFlat 7fd4b33dc5 Merge pull request #3670 from dotCooCoo/fix/batch-revert-keeps-the-view-artboard
Artboards: an action or Batch… that reverts or switches documents keeps the active artboard
2026-10-23 00:46:58 +07:00
bflatastic 26b9cf84ad Type: colour emoji fonts with bitmap glyphs draw and export
Squashed from #3561 onto main.
2026-10-23 00:44:31 +07:00
BFlat 9b2173611f Merge pull request #2975 from nrq6993-a11y/stream/vc-feat3-g12-low-res-proxy-eps
G12: honor Use Low Resolution Proxy for Linked EPS (#394)
2026-10-23 00:37:02 +07:00
BFlat b50fbc08cf Merge pull request #3668 from Taha-Mahmoodi/fix/dither-followups
Gradients: dithered fills sample only what shows; review follow-ups to #3637
2026-10-23 00:23:42 +07:00
dotCooCoo 44c306f142 Artboards: an action or Batch… that reverts or switches documents keeps the active artboard
With the Artboard tool, File › Revert kept the active artboard, but a
command.batch with a file.revert step made artboard 1 the active one.
A batch that switched to another document and back, and the Actions
panel menu's Batch… over a folder whose files it closes or over data
sets with a Revert step, did the same. Each of them starts the tool
afresh and ends in the document it started in, and the app moved the
view to the fresh tool's artboard 1. Only a plain file.revert was
handled, by its command id.

The engine now counts the times switching or closing documents, or
replacing the active one, starts the tool afresh (Session::tool_resets),
and records whether the tool's options were set after that
(Session::tool_set_since_reset). When the count changes during a
command and nothing set the tool's options after it, the app starts
the tool on the active artboard, as it does when a command chooses
another document. When a later step chose the tool's artboards (a
Paste or Cut after the revert), the artboard the tool then has active
becomes the active one.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-23 00:20:54 +07:00
bflatastic 30360a67a0 G12: honor Use Low Resolution Proxy for Linked EPS (#394)
Squashed from #2975 onto main.
2026-10-23 00:14:52 +07:00
BFlat 24fadbd322 Merge pull request #3664 from dotCooCoo/feat/shape-builder-artwork-style-rule
Shape Builder: Pick Color From Artwork takes the look where the drag starts, else where it ends
2026-10-23 00:11:07 +07:00
BFlat 72dce93463 Merge pull request #3662 from nrq6993-a11y/stream/vc-featD-g8-art-style-filter-cap
G8: Regression test for art style filter cap
2026-10-23 00:10:48 +07:00
BFlat 40e40bf8cb Merge pull request #3660 from nrq6993-a11y/stream/vc-issue-3635-linux-maximize
Restore and remember maximized window state
2026-10-23 00:10:28 +07:00
BFlat ab330e4adc Merge pull request #3649 from nrq6993-a11y/stream/vc-feat3-g4-mixed-hyphenation-dialog
G4: Mixed Hyphenation dialog fields
2026-10-23 00:10:09 +07:00
BFlat dc38eb6bee Merge pull request #3648 from nrq6993-a11y/stream/vc-feat1-g4-mixed-paragraph-direction
G4: Mixed paragraph direction leaves direction buttons unselected
2026-10-23 00:09:51 +07:00
Taha-Mahmoodi 6b10b159b9 Gradients: dithered type in PDF keeps its resolution when scaled and stays searchable
Review follow-up (CodeRabbit): a gradient drawn as pixels was sized from its
area in the space it is drawn in, so type scaled 4 times got a quarter of the
raster effects resolution; the image is now sized from the area on the page,
within the same pixel caps. And type whose fill is drawn as art (a dithered
or freeform gradient's image, a pattern) went out as outlines only; its
characters are now also written as invisible text (rendering mode 3), as
unpainted type already is, so they can be selected and searched.
2026-10-23 00:08:53 +07:00
Taha-Mahmoodi 7c9edfaf7f Gradients: an outlined stroke leaves dither behind, and paint.setStroke refuses it
Review follow-up (CodeRabbit): paint.setStroke kept `dither: true` from its
gradient, and a stroke can hold dither anyway (swapped from a fill). Outline
Stroke and appearance expansion copied the stroke's paint into the outline's
fill, which then drew dithered although the stroke never did. The outline's
fill now goes without dither (Paint::stroke_as_fill), and paint.setStroke
refuses a gradient with `dither: true`, as paint.editGradient does.
2026-10-23 00:08:53 +07:00
Taha-Mahmoodi 159e20053c Gradients: a dithered gradient without stops draws nothing, as an undithered one does
Review follow-up (CodeRabbit): a dithered fill skipped set_paint's guard for
a gradient without stops and sampled a transparent grid to draw nothing. It
now takes the same no-fill path.
2026-10-23 00:08:52 +07:00
Taha-Mahmoodi 4062e74ff3 Gradients: a dithered fill samples only the part of its grid the view shows
Review follow-up (CodeRabbit): a large dithered gradient mostly off-screen
sampled its whole grid (up to 4096 x 4096 cells, 64 MiB) to draw a small
visible part, and did it again on each zoom step. The renderer now samples
only the cells the render target can show, a cell more each way for the
bilinear mix, snapped out to 256-cell tiles so a pan within them reuses the
grid. Cells keep their place in the whole grid, so the gradient and its
noise are the same pixels as before (GradientPaint::pixels_within).
2026-10-23 00:08:52 +07:00
dotCooCoo fffc8418d9 Shape Builder: Pick Color From Artwork takes the look where the drag starts, else where it ends
With Pick Color From Artwork, a Shape Builder merge took the look of
the front-most object over the first covered region the drag touched,
and the current fill when the drag touched only areas no object
covers. The reference app's combine page gives the look of the object
where the drag starts, else of the object where it ends, else of the
topmost selected object.

shapeBuilder.merge now follows that rule: the merged shape takes the
appearance, opacity and blending mode of the front-most of the objects
it is given (the selection by default) under the first point, else of
the one under the last point, else of the front-most of those objects.
A drag from a hole across a gray bar into a red square now gives a red
shape, where it gave the bar's look. A click in an area no selected
object covers, such as a hole that gray bars enclose or a triangle
that lines enclose, takes the front-most selected object's look
instead of the current fill. Pick Color From Color Swatches works as
before. The tool already sends the press point first and the release
point last.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-22 23:56:01 +07:00
Cursor Agentandnrq6993-a11y 00f557e02e G8: regression test for art style filter cap
## Summary
Illustrator `%AI9` art styles already stop reading filters after 4096 entries so
hostile styles cannot grow without bound. This adds a regression test that
importing a style with more than `MAX_FILTERS` basic filters finishes with a
bounded effect list, matching the existing hardening behaviour.

## Tests
- `art_styles_ignore_filters_beyond_the_cap` in `crates/eps/src/import/ai/style.rs`

Part of gap G8

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 23:56:01 +07:00
Cursor Agentandnrq6993-a11y 0490ca0788 fix(linux): restore and remember maximized window state
## Summary

On Linux, asking winit to maximize before the window is mapped often does
nothing, and egui sometimes omits the maximized viewport flag while the
window manager still has the window maximized. Defer maximize until the
first frames after startup, and read maximized state from winit when
saving geometry for the next launch.

## Tests

cargo test -p vectorcraft window::
cargo clippy -p vectorcraft --all-targets -- -D warnings

Closes storytold/vectorcraft#3635

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 23:56:01 +07:00
BFlat 45f6e99eb1 Merge pull request #3647 from dotCooCoo/fix/package-names-a-rejected-path-once
Package: a file the writer refuses is named once in the error
2026-10-22 23:54:23 +07:00
bflatastic 329916c2b8 G4: Mixed Hyphenation dialog fields
Squashed from #3649 onto main.
2026-10-22 23:46:14 +07:00
Cursor Agentandnrq6993-a11y 2f3ccb7b2c G4: mixed paragraph direction leaves direction buttons unselected
## Summary
When the Paragraph panel acts on more than one paragraph whose displayed
direction differs (explicit LTR/RTL or Auto resolved from content), neither
Left-to-Right nor Right-to-Left is highlighted, matching alignment and the
other mixed-value paragraph controls in Adobe Illustrator.

## Tests
- `paragraph_mixed_direction_buttons_show_neither` in `panels/paragraph.rs`

Part of gap G4

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 23:45:28 +07:00
BFlat dbf6258eae Merge pull request #3641 from pat26-dotcom/fix/3623-pdf-import-word-gap
Fix #3623: a word gap between two sizes reads as a space in PDF import
2026-10-22 23:41:26 +07:00
dotCooCoo 4a4c8c9d49 Package: a file the writer refuses is named once in the error
File › Package… put the file's path in front of every error its writer
returned. A refusal from the automation write root already names the
path, so the dialog's error and the status bar named the file twice,
for example on Windows for a document named aux:
`<path>: automation path rejected: reserved device names are not
allowed: <path>`.

The dialog now keeps a writer error that already contains the path as
it is, and puts the path in front of one that does not, such as the
desktop's "Access is denied. (os error 5)".
2026-10-22 23:36:57 +07:00
BFlat 6e091a33f5 Merge pull request #3636 from MiQ71/fix-3447-spiral-dialog
Fix Spiral tool click options dialog
2026-10-22 23:27:46 +07:00
Patrick LenzenandClaude Opus 5.5 6903bb7ef8 Fix #3623: a word gap between two sizes reads as a space in PDF import
PDF import measured each gap between two glyphs against the larger of
the line's first glyph size and the next glyph's. A word set beside a
much larger one, apart by positioning and no space glyph (a browser's
margin between two sizes), stayed under a fifth of that em: "≤ 4" at
20 pt and "hours" at 8 pt, 3.4 pt apart, came in as "≤ 4hours".

A gap is now measured against the smaller of the two glyphs beside it.
Lines in one size read their gaps as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:14:35 +07:00
BFlat b35a6b1573 Merge pull request #3638 from dotCooCoo/fix/cmd-enter-shortcut-skips-the-tool-enter
Shortcuts: a command on Cmd+Enter or Cmd+Escape runs without the tool's Enter or Escape
2026-10-22 23:11:43 +07:00
bflatastic a99d81e9aa Fix Spiral tool click options dialog
Squashed from #3636 onto main.
2026-10-22 22:57:26 +07:00
BFlat d73946ba46 Merge pull request #3637 from Taha-Mahmoodi/feat/gradient-dither
Gradients: Dither in the Gradient panel draws a gradient fill with fine noise that hides banding
2026-10-22 22:56:41 +07:00
dotCooCoo bd12b48e02 Shortcuts: a command on Cmd+Enter or Cmd+Escape runs without the tool's Enter or Escape
Enter and Escape went to the active tool whatever modifiers were held,
and the command bound to the chord then ran as well. With a command
bound to Cmd+Enter, Cmd+Enter ended the Pen tool's path, opened the
Move dialog with the Selection tool, or typed a line break while the
Type tool edited text, and then ran the command. With a command bound
to Cmd+Escape, Cmd+Escape ended the Pen path, the Type tool's text
editing or isolation mode before the command ran.

A press of Enter or Escape whose chord a command is bound to, matched as
the shortcut dispatcher matches it, now runs only that command, also
when the tool claims the key. While the Type tool edits text, only Cmd
chords count. While a tool is in the middle of an interaction (a drag,
or between the clicks of a tool such as Blend or Dimension), and for
plain Enter and Escape and chords that no command is bound to, the key
goes to the tool as before.
2026-10-22 22:44:58 +07:00
Taha-Mahmoodi cc1a660a6d Gradients: Dither in the Gradient panel draws a gradient fill with fine noise that hides banding
Illustrator 30.0's gradient Dither, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html):
a Dither checkbox in the Gradient panel; dither converts the gradient into
pixels with subtle noise, works with gradient fills only, and follows the
Document Raster Effects resolution.

- Gradient gains `dither` (serde default false, written only when on, so
  older files open unchanged and new files keep it).
- The renderer draws a dithered linear or radial fill as an image paint: the
  gradient sampled a cell per device pixel or finer (quarter-octave steps,
  cached per thread), each channel given seeded triangular noise of up to a
  level before rounding. Same pixels every run; strokes and undithered fills
  draw exactly as before. PNG and other raster exports go through it.
- SVG and PDF write a dithered fill as an image at the document's raster
  effects resolution, clipped to the shape (the freeform-gradient path);
  characters' fills stay vector gradients in SVG, with a warning.
- paint.editGradient and paint.setFill take `dither`; strokes refuse it.
  The Gradient panel's Dither box is dimmed behind the Stroke proxy.
- i18n rows in all 24 catalogs; docs: G13, target-app-parity, ui-parity,
  mcp.md, plugins.md.
2026-10-22 22:38:42 +07:00
Taha-Mahmoodi a1d8952bc8 Gradients: a linear or radial gradient can be drawn as pixels, and SVG and PDF write such a gradient as an image
Shared groundwork for gradient options SVG and PDF can't express (Illustrator
30's dither and perceptual gradients): GradientPaint::pixels samples a placed
linear or radial gradient on a grid (Ramp: its stops, midpoints as stops, mixed
as the renderer mixes them), and draws_as_pixels says which gradients are drawn
that way (freeform ones, so far). The SVG and PDF writers' freeform-gradient
image path takes any such gradient, a linear or radial one at the document's
raster effects resolution. No change in output yet.
2026-10-22 22:38:41 +07:00
Taha-Mahmoodi 4bb6f32911 Gradients: built with Gradient::new, so a new option touches one place 2026-10-22 22:38:41 +07:00
BFlat 30d1948f51 Merge pull request #3496 from dotCooCoo/feat/place-links-pdf-and-ai
Place: Link keeps a placed PDF or .ai linked to its file
2026-10-22 22:09:29 +07:00
dotCooCoo c4ac0c5df5 Package: copies the fonts of the type on a linked PDF or .ai page
A PDF or .ai page placed linked is a placed document, and File ›
Package listed only the fonts of the document's own type, so the fonts
of the page's type were left out of Fonts/ where an embedded page's
were copied. Package now reads each linked page a document places and
adds the fonts of its type, also when another document placing the same
file copied it first, and also with copyLinks off.
2026-10-22 21:52:24 +07:00
dotCooCoo 22b6f2a89c Place: Link's tooltip treats a PostScript .ai as a file it embeds
The Place dialog classed every .pdf, .ai and .ait path as a document
that Link places as one locked object. A .ai saved without PDF
compatibility is PostScript, and file.place embeds it as editable art
with Link on or off, so the tooltip was wrong for it. The dialog now
reads the file's header with the same PostScript check file.place uses,
and describes such a file as it does other files Link does not keep
linked.
2026-10-22 21:52:23 +07:00
dotCooCoo 9a72eadc51 i18n: Turkish row for Place's not-a-document message
Turkish is now a complete-message language, so the message file.place
gives for a file that is not a VectorCraft document, a PDF or a
PDF-compatible .ai file needs a tr row.
2026-10-22 21:52:23 +07:00
dotCooCoo 781e62b9cc Place: Link keeps a placed PDF or .ai page linked, as it does images
The File Handling preference Link Placed PDF and .ai Files
(linkPlacedPdf) is removed. File › Place with Link now places a page of
a PDF, .ai or .ait file read as PDF pages as a linked placed document,
as it links a raster image, and link: false embeds the page. Link is on
by default in the Place dialog and in file.place, so a PDF or .ai page
placed from a file, which used to be embedded, is now linked unless
Link is off. A password or textAs: "outlines" still embeds, and so do
EPS and PostScript .ai files.

The Place dialog's Link tooltip describes a PDF or .ai file as a
document.

A .ai file read from its editing data (its PDF part only a placeholder)
finds the files its art places by name, beside it. Package now copies
those files into the same folder as the .ai file, and warns when one has
to be copied under another name.
2026-10-22 21:52:23 +07:00
dotCooCoo 3a1d407f1a Place: a placed PDF or .ai can stay linked to its file (preference, off by default)
File › Place with Link kept a link only to raster images and VectorCraft
documents. A PDF, .ai or .ait page was always embedded as a group, so
Update Links never saw a newer version of the file.

A new preference, File Handling › Link Placed PDF and .ai Files
(linkPlacedPdf), is off by default. With it on, Link places the page as
a placed document, as it places a VectorCraft document: the object
keeps the file's bytes, a preview, the page number and the page's box
(crop, art, trim, bleed or media, stored as page_box). Links check,
update and relink it, Break Link and Object › Expand give the group
placing it without Link gives, every output draws the page's vectors,
and saving and reopening keep the link. With the preference off, or
with a password or textAs: "outlines", the page is embedded as before.
EPS and PostScript .ai files are still embedded.

Package copies a placed PDF or .ai file as it is. The Place dialog's
Link tooltip describes a PDF or .ai file as a document while the
preference is on. Relinking a placed document to a file that is not a
VectorCraft document, a PDF or a PDF-compatible .ai file says so.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-22 21:52:23 +07:00
BFlat def14fc8b2 Merge pull request #3631 from Taha-Mahmoodi/feat/3546-multiple-automation-roots
Automation: --automation-read-root and --automation-write-root may each be given more than once (#3546)
2026-10-22 21:41:28 +07:00
BFlat 0919e54785 Merge pull request #3630 from Taha-Mahmoodi/feat/3147-cli-commands-file
CLI: run --commands reads the steps from a JSON file or standard input (#3147)
2026-10-22 21:41:12 +07:00
bflatastic 1b7cfc8bee Automation: --automation-read-root and --automation-write-root may each be given more than once (#3546)
Squashed from #3631 onto main.
2026-10-22 21:36:15 +07:00
Taha-Mahmoodi 49d69d48e5 CLI: a malformed JSON Lines entry names its step as well as its line (#3147)
Review follow-up (CodeRabbit): a line that wasn't valid JSON was reported
by its line only, while every other --commands error names the run's step.
It now says `step N (line L)`, N counted with the --cmd steps before it.
run_refuses_malformed_commands_naming_the_step expects `step 2 (line 3)`;
it fails without the change.
2026-10-22 21:36:05 +07:00
BFlat 0c41f0af55 Merge pull request #3629 from Taha-Mahmoodi/feat/conte-crayon
Effects: Sketch › Conté Crayon draws the dark areas in a black crayon and the light areas in a white one on textured paper
2026-10-22 21:34:59 +07:00
Taha-Mahmoodi 87fdd4a370 CLI: run --commands reads the steps from a JSON file or standard input (#3147) 2026-10-22 21:34:37 +07:00
Taha-Mahmoodi 9097c4a564 Docs: G2 lists the 57 filters once and its table estimate matches; ROADMAP records Conté Crayon in its revision history
Review follow-up (CodeRabbit): the two older G2 bullets counting 46 and 49
filters were superseded by Conté Crayon (57/57) and are folded into one
current bullet; the G2 row in the ranked table and the entry's estimate now
both say 4–6 h, as ROADMAP.md and roadmap.md do. ROADMAP.md's revision
history gains its 2026-10-22 row.
2026-10-22 21:34:37 +07:00
Taha-Mahmoodi a5778fcfca Effects: Conté Crayon's Foreground Level and Background Level are translated in every catalog 2026-10-22 21:18:35 +07:00
Taha-Mahmoodi bd6d076484 Effects: Sketch › Conté Crayon draws the dark areas in a black crayon and the light areas in a white one on textured paper 2026-10-22 21:18:34 +07:00
BFlat cee30f05b6 Merge pull request #3616 from devius/ka-update
i18n(ka): Georgian for 7 strings added since #2045
2026-10-22 19:30:14 +07:00
BFlat b0be9edabb Merge pull request #3613 from Taha-Mahmoodi/fix/3544-svg-title-artboard
Fix #3544: an SVG of one artboard on its own has the artboard's name as its title
2026-10-22 19:19:23 +07:00
BFlat 896f3aa4a1 Merge pull request #3612 from Taha-Mahmoodi/fix/3370-stroke-scrub-bounds
Fix #3370: stepping a number field stops at its bounds: a stroke weight at 0, a miter limit at 1
2026-10-22 19:19:02 +07:00
BFlat ab1f9f2b92 Merge pull request #3611 from Taha-Mahmoodi/fix/3545-draw-path-invalid-paint
Fix #3545: draw_path and draw_shape with a paint the engine refuses leave the document unchanged
2026-10-22 19:18:41 +07:00
Davit MatchakhelidzeandClaude Opus 5.5 2e2e6a2f8e i18n(ka): Georgian for 7 strings added since #2045
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF
2026-10-22 19:18:01 +07:00
BFlat a1ceed08fc Merge pull request #3548 from dwetscher/feat/affinity-designcraft-port
AF.5: Affinity import — strokes, text decoration, text placement, outline effects, CMYK photos
2026-10-22 19:06:31 +07:00
Taha-Mahmoodi 4f9b620dc6 Fix #3544: an artboard name XML can't hold falls back to the document title
Review follow-up (CodeRabbit): the blank-name check ran on the raw name, so
a name made only of characters XML can't hold (control characters) wrote an
empty <title>. The name is sanitised first, then the fallback applies. The
title test covers such a name; it fails without the change.
2026-10-22 18:56:34 +07:00
Taha-Mahmoodi 439d32fcf8 Fix #3544: an SVG of one artboard on its own has the artboard's name as its title
The SVG writer always wrote the document's title in <title>
(crates/svg/src/export.rs), so every file of Export As with Use
Artboards, Export for Screens or an `artboard`/`range` export was titled
after the document ("Icons"), whatever its artboard was called. The
writer's `artboard` option can't tell those apart from Save: Save writes
the first artboard too, standing for the whole document.

ExportOptions has a new `artboard_title` (set by the caller, as
`artboard` is, not deserialized): the file is that one artboard, exported
on its own, and its <title> is the artboard's name (XML-escaped as
before), or the document's title when the artboard has no name. The
engine's SVG encoder (fileio/svg.rs) sets it exactly when the export
names its artboards, which is also when a file holds only that
artboard's art (#550): Export As per artboard, document.export /
serialize with `artboard` or `range`, MCP export, and Export for Screens'
artboards, assets, layers and selections (each written as an artboard
named after it). Save and Save As keep the document's title. Export for
Screens' Full Document draws the art bounds as one artboard, which is now
named after the document, so its SVG keeps the document's title too.
docs/file-format-parity.md's SVG row says so.

Tests: title_control_chars::an_artboard_exported_alone_is_titled_after_it
(crates/svg: two named artboards, the second named with markup;
exporting either alone gives its escaped name, the default and an
artboard without artboard_title give the document's title, an unnamed
artboard falls back to it) and
tests_svgenc::an_svg_of_one_artboard_is_titled_after_it (the engine:
`artboard` 0 and 1, useArtboards range all, Save's SVG, Export for
Screens per artboard, an asset and Full Document). Both fail without the
change (the title was "Icons") and pass with it.
2026-10-22 18:56:34 +07:00
Taha-Mahmoodi cc499d7e8e Fix #3370: each step stops at the bounds, so a step back in the same frame starts from there
Review follow-up (CodeRabbit): step_value clamped once after all of a
frame's steps, so at the lower bound a step down then a step up in the
same frame left the field at the bound. Each step is now clamped as it is
applied. The Stroke panel test steps down and up in one frame at each
bound and expects one step above it; it fails without the change.
2026-10-22 18:56:33 +07:00
Taha-Mahmoodi 28ac044c74 Fix #3370: stepping a number field stops at its bounds: a stroke weight at 0, a miter limit at 1
Wheel notches and the arrow keys over a focused number field step the
value its text shows (widgets::step_value), and nothing there knew the
field's bounds: the Stroke panel's Weight went on to -3 pt and its Limit
to 0 x in the field, while the panel only clamped what it applied (the
spinner's min, Limit's clamp to 1..500), so the next step went on from
the out-of-range text. A label scrub (scrub::field) had the same gap.

step_value and scrub::field now take the field's bounds and stop at
them, so the field shows where they stopped and the value applied is
the same. The fields that have bounds pass them:
- spin_field / spin_field_auto / spin_plain: their `min` (the spinner
  and a typed value already stopped there);
- range_field: its range; it now also takes a value that may be blank
  (None), as mixed_field does;
- opt_field (dash and gap lengths): 0;
- the Revolve dialog's values: their range.
Fields that clamped only what they applied now say their range with
range_field instead: the Stroke panel's Limit (1..500), the Transparency
and Properties opacity (0..100), the Transform panel's polygon sides,
and the dialogs' bounded counts, percentages and lengths (Blend Options,
Envelope, Flattener, Liquify, Freehand, Live Paint, Path ops, Perspective
Grid, PNG ppi, Print, Save for Web, Shape Builder, Slices, Swatch
Options, Symbol options, Justification, Dimension, PDF page, the effect
dialogs' number rows). docs/ui-parity.md's number-field row says so.

Test: panels::stroke::tests::the_wheel_stops_weight_at_zero_and_limit_at_one
(the Stroke panel in a headless frame; focus Weight at 1 pt and turn the
wheel down four notches: the field shows 0 pt and the stroke is 0 pt;
the same with Limit at 2 x: 1 x and a limit of 1). It fails without the
change (the field showed -3 pt) and passes with it; the other numeric
field, scrub and dialog tests still pass.
2026-10-22 18:56:33 +07:00
Taha-Mahmoodi 648a8f5104 Fix #3545: draw_path and draw_shape with a paint the engine refuses leave the document unchanged
The MCP drawing tools ran the creating command (path.create, shape.*)
and only then paint.setFill, paint.setStroke, stroke.set,
object.setProps and effect.apply, each as a command of its own.
check_draw_options checked dash, cap, join, opacity, name and effects up
front, but not the paints: a fill or stroke the engine can't read
(stroke: "currentColor"), or a swatch the document lacks, failed after
the object was made, so the call returned an error and left the path,
its undo step and its SVG node behind. Each styling command was also an
undo step of its own.

The tools now send the creating command and the styling commands as one
command.batch (crates/mcp/src/tools.rs, draw): the engine rolls the
whole batch back when a step fails, so a refused paint leaves no object,
no undo step and nothing in the SVG, and a good call is one undo step
named as the creating command names its own (Pen, Rectangle, …). The
styling steps act on the new object, which the creating command selects,
with item: null (its whole appearance), as the ids did before. The
reply gives the failed step's own error, without the batch's prefix.
docs/mcp.md says so.

Test: tests_drawopts::a_bad_paint_draws_nothing (invalid fill, stroke,
a missing swatch and a non-numeric strokeWidth on draw_path and
draw_shape: error, node count, undo history and SVG export unchanged;
a good call adds one undo step named Pen / Rectangle). It fails without
the change (draw_path with stroke "currentColor" drew a path) and passes
with it; the other draw option and command_batch tests still pass.
2026-10-22 18:56:32 +07:00
dwetscherandClaude Opus 5.5 2d7004f972 AF.5: cap dash patterns at 64 lengths
A line style's `Patn` was copied twice (filtered, then scaled) whatever its
length, before any budget saw it. Patterns longer than 64 lengths now import
as solid lines, with a warning.

Addresses CodeRabbit's third review on #3548.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-22 18:44:39 +07:00
BFlat 2f27759bcc Merge pull request #3614 from storytold/land/3610
Fix EPS counted metadata reads after CRLF line endings
2026-10-22 18:34:10 +07:00
dwetscherandClaude Opus 5.5 b3ccdf01a7 AF.5: nested overlays innermost first; embedded master pages; text outline budget
- Overlays bake child-first: a group's overlay now lies over its children's
  (they used to be applied after it and win). Each one pays out of the
  import's overlay work budget.
- Master page instances inside embedded documents are left out with a warning:
  their indices count the embedded document's masters, which aren't kept, and
  would show the outer document's.
- A text outline's gradient stops and dashes are charged to the text attribute
  budget, like the fill's.

Addresses CodeRabbit's second review on #3548.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-22 18:24:57 +07:00
dwetscherandClaude Opus 5.5 f73a3df974 AF.5: bound hostile Affinity files; fuzz the new import paths
- One budget for the whole read (layers, pixels, characters, table
  cells), shared with embedded documents and charged also when their
  reading fails; the first reading is dropped before the second.
- Tables: at most 65 536 cells; lines and cell formats that don't
  match the grid are left out instead of shifted.
- Paragraph formats matched to runs by binary search; font names
  longer than 256 bytes are dropped.
- Overlays: one work budget per import; encoded originals decoded
  within their layer's size; table cell outlines recoloured too.
- Engine: master page copies capped, each page's master found in
  linear time, line gaps looked up once per font, the working CMYK
  table built once, objects past MAX_COORD left out.
- Embedded content stays with its node; pixel masks under artistic
  text are read whole and include stroke reach; gradient overlays on
  hidden layers are kept.
- Render: contexts and raster effect images stay within
  MAX_RASTER_SIDE per side (vello overflowed near u16::MAX; found by
  the new fuzz target).
- Tests: affinity tests/import_limits.rs, feature samples in
  import_fuzz.rs, shared builders in engine/tests/support/.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-22 18:24:57 +07:00
dwetscherandClaude Opus 5.5 fd50bd6f84 AF.5: Affinity import — strokes, text decoration, text placement, outline effects, CMYK photos
- Strokes set to None no longer import as black 1px strokes.
- Underline and strikethrough are kept.
- Frame text starts at cap height; lines hanging out of a frame make it taller;
  mixed-alignment artistic text lines up within its longest line; line breaks
  inside a paragraph get no paragraph spacing.
- Table cell text taller than its cell is centred and stays visible.
- Outline effects on stroked shapes are drawn around the stroke.
- CMYK pixel layers go through the document's embedded CMYK profile; without
  one, opaque layers stay CMYK.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-22 18:24:57 +07:00
BFlat a98ca04196 Merge pull request #3608 from dotCooCoo/fix/export-as-uses-the-picked-format-extension
Export: a typed extension of a format the export doesn't write is replaced
2026-10-22 18:22:47 +07:00
Don Omondi 76399175eb M4: preserve counted EPS metadata across CRLF line endings 2026-10-22 18:19:42 +07:00
BFlat dee53e3413 Merge pull request #3609 from storytold/fix/mesh-banding
Gradient mesh: each tessellated quad shades between its corner colours, no banding
2026-10-22 18:14:49 +07:00
dotCooCoo cbf2d9cc43 Export: a typed extension of a format the export doesn't write is replaced
An export through the desktop save panel kept any typed extension
that names a known format, whatever format it wrote. Export As with
PNG chosen and card.svg typed wrote PNG data to card.svg, and Save as
PDF, Save for Office Documents, Text Export Options, Export Library
and the other writes that go through pick_path did the same.
document.exportSelection with a format suggested and added .png
whatever the format.

Such an extension is now replaced with the written format's, as Save
As has done since #3372: card.svg is written as card.png. An extension
of the written format stays as typed in any letter case (photo.JPEG
for JPEG), Export As SVG (and file.exportAs, file.export.svg) keeps
art.svgz and writes it compressed, and document.exportSelection
without a format still writes the format the typed extension names.
With a format, it suggests and adds that format's extension.

Docs: docs/target-app-parity.md.
2026-10-22 18:13:11 +07:00
bflatastic 00fe2be469 Gradient mesh: each tessellated quad shades between its corner colours, no banding 2026-10-22 17:53:20 +07:00
BFlat 385f1588fc Merge pull request #3583 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 17:48:15 +07:00
bflatastic fe8b8c8106 README: the app icon above the title 2026-10-22 17:47:51 +07:00
BFlat fc996e0e03 Merge pull request #3560 from dotCooCoo/feat/package-off-ui-thread
Package: File › Package collects and writes the package on the background thread
2026-10-22 17:30:39 +07:00
dotCooCoo 052bbb14b4 Package: File › Package collects and writes the package on the background thread
File › Package… read every linked file and font and wrote the package
on the UI thread, so the window stopped responding until the package
was written. For a document that links 50 images of 21 MB each, OK
with Background Export off, which does the same work, takes a median
of 2.25 s in a release build on Windows.

With Enable Background Export on (Preferences › File Handling), OK now
takes the options, the report's Document Info text and a snapshot of
the document, and closes the dialog. The thread that writes background
saves and exports then collects the links and fonts and writes the
package. The same OK takes 0.001 s, and the package holds the same
bytes. A save of unsaved changes that Package starts is written first.
With the preference off, on the web and over MCP and scripts, the
package is made at once with the same reply as before.

Docs: docs/target-app-parity.md.
2026-10-22 17:20:48 +07:00
BFlat f553be25f7 Merge pull request #3476 from makesomethingshit/fix/3416-recolor-edit-scroll
Fix Recolor Artwork Edit overflow with large palettes
2026-10-22 17:17:38 +07:00
makesomethingshit e56e40dd85 M3.78: keep Recolor Edit chips within the screen (#3416) 2026-10-22 17:10:16 +07:00
BFlat bbaf7b02c3 Merge pull request #3557 from dotCooCoo/fix/artboard-paste-makes-the-new-artboard-active
Artboards: the active artboard follows the Artboard tool after Paste, Cut and tool.setOption
2026-10-22 17:07:02 +07:00
dotCooCoo 5d4cac31cf Artboards: the active artboard follows the Artboard tool after Paste, Cut and tool.setOption
With the Artboard tool, Paste and Paste in Place select the pasted
artboards, Cut gives the tool the artboard before the first cut one
(the new first artboard when artboard 1 is cut), and tool.setOption
sets its selected or active artboard. The active artboard, the view's,
stayed where it was, so the navigator showed another artboard than the
tool and the Artboards panel. document.activate and File › Revert
started the tool on artboard 1, while choosing a document's tab starts
it on that document's active artboard.

After a command that changes the artboards the Artboard tool has
selected, the app now makes the tool's active artboard the active one,
as a click with the tool does. After a command that chooses another
document, and after Revert, the tool starts on the document's active
artboard.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-22 16:59:38 +07:00
BFlat 22c83b5951 Merge pull request #3554 from Taha-Mahmoodi/fix/3443-layers-scrollbar
Fix #3443: the Layers list's scroll bar no longer covers the rows' selection marks
2026-10-22 16:50:18 +07:00
Taha-Mahmoodi 7141932dfb Fix #3443: the Layers list's scroll bar no longer covers the rows' selection marks
The Layers list scrolls in its own scroll area, which kept egui's floating
scroll bar when #3093 gave the panel bodies a bar in a column of their own.
Hovering the list showed the bar over the right edge of the rows, over the
target circles and the selection squares the user clicks to select a layer.

The list now uses the menus' slim solid bar (list_scroll_style) in its own
column, and the rows end beside it. The list knows its total height before
it lays the rows out, so the bar is shown exactly when the rows overflow
(decided up front): the rows never shift sideways a frame after the bar
appears.

Test: dock::tests::the_layers_scroll_bar_covers_no_row (40 layers in a
1440x600 window: no row's accessibility rect intersects the bar). It fails
without the change and passes with it. The Layers tests that pin row
positions across frames still pass. Checked live: the bar sits beside the
rows.
2026-10-22 16:43:39 +07:00
BFlat bbc9bd3de2 Merge pull request #3551 from Taha-Mahmoodi/fix/2452-hover-while-panning
Fix #2452: panning or zooming over detailed art no longer hit-tests it every frame
2026-10-22 16:17:18 +07:00
BFlat 423bc71705 Merge pull request #3550 from Taha-Mahmoodi/fix/3093-panel-scrollbars
Fix #3093: panel scroll bars take a column of their own instead of covering controls
2026-10-22 16:17:03 +07:00
BFlat c60ad8dcfa Merge pull request #3549 from Taha-Mahmoodi/fix/2103-artboard-nudge
Fix #2103: arrow keys with the Artboard tool move the selected artboards and their art
2026-10-22 16:16:48 +07:00
Taha-Mahmoodi 6ee972c4da Fix #2452: panning or zooming over detailed art no longer hit-tests it every frame
With the pointer resting over art while the view moves (trackpad or wheel
scrolling, pinch zoom, a Hand/space pan), the document point under the
pointer changes every frame, and the canvas hit-tested the art there on
each one, up to three times: Object Highlighting (canvas.rs
hover_highlight -> selection_hit_at) and the Selection tool's cursor
(select.rs cursor: hit_test, and with Snap to Point the nearest-anchor
search). Over a detailed engraving in a clip group (the reporter's
PhotoCraft icon: one path of tens of thousands of segments) each of those
costs roughly 3-6 ms, so every frame of the move took 12-15 ms of UI time;
off the art they return at once.

- canvas.rs: the canvas notes when its view (rect, zoom, centre,
  rotation) last changed. Until it has been still for HOVER_STILL_SECS
  (0.15 s) it draws no hover highlight and keeps the cursor the tool last
  gave for the same tool and modifier keys instead of asking again; it
  asks for a repaint then, so both come back without the pointer moving.
  Pointer moves still reach the tool as before.
- test: a_moving_view_leaves_the_art_under_the_pointer_unhighlighted_until_it_stops
  (wheel frames draw no hover outline and keep the cursor; once still,
  the outline returns and the cursor follows the art). It fails on main
  at the first wheel frame.

Measured on an Apple M4 (release build, 1440x900 window, the PhotoCraft
app icon SVG fitted at 138%, Selection tool, Smart Guides and Snap to
Point on), median UI frame time over 60 wheel-pan frames with the pointer
resting:
  before: over the art 12.3-15.0 ms, off the art 1.0 ms
  after:  over the art 0.4-1.0 ms,  off the art 0.6 ms
The render readout is the art's own raster time on the render worker,
which this change doesn't touch.
2026-10-22 16:06:55 +07:00
Taha-Mahmoodi a8bdf5043f Fix #3093: panel scroll bars take a column of their own instead of covering controls
The panels' scroll areas used egui's default floating scroll bar, which
is drawn over the right edge of the content: in a window too short for
the Properties panel it covered the Image Trace button's dropdown arrow
and the W/H constrain link, and in a dock column too tall for the window
it covered the panels' menu buttons and right-hand controls. Menus
(#2086, list_scroll_style) and the toolbar and dock icon column (#808,
strip_scroll) had already been given bars that keep clear of their
items; the panel bodies hadn't.

- widgets: panel_scroll shows a panel's scroll area with the menus' slim
  solid bar (list_scroll_style), in a column of its own at the right
  edge; scroll areas inside the body keep their own style.
- dock.rs: the docked Properties panel and Properties in a flyout,
  floating group or shared dock group use it.
- floating.rs: a dock column too tall for the window uses it.
- panel_docking.rs: a panel's body in the shared dock uses it, laid out
  as wide as the scroll area leaves beside the bar; the shared dock's
  icon column scrolls with strip_scroll, as the dock's icon column does.
- test: the_properties_scroll_bar_covers_no_control (a 1440x600 frame of
  the dock with an image selected: no control's rect meets the
  Properties scroll bar). It fails on main with "Tracing Presets" (the
  Image Trace arrow) and "Constrain Width and Height Proportions" under
  the bar.

Verified live at 1440x600 with an image selected: before, the hovered
bar sat over the Image Trace arrow and the right ends of the Quick
Action buttons, and a dock column's bar over the Swatches panel menu;
after, both bars sit in their own 8 pt column and nothing is covered.
2026-10-22 16:06:54 +07:00
Taha-Mahmoodi b0d5ab3f68 Fix #2103: arrow keys with the Artboard tool move the selected artboards and their art
With the Artboard tool the arrow keys did nothing to the artboards: the
tool claimed only Escape, Delete and Backspace (tools/src/xform/artboard.rs
claims_key), so the arrows fell through to object.nudge in
ui-egui/src/shortcuts.rs, which moved the selected art on its own (or
nothing, with no art selected) and left the artboard where it was.

- artboard.rs: the tool claims the arrow keys and turns each press into
  one artboard.move of the selected artboards that aren't locked, by the
  keyboard increment (Preferences > General > Keyboard Increment, read
  from the tool context as Touch Type does), Shift ten times as far as
  object.nudge does, with moveArt from the tool's Move/Copy Artwork with
  Artboard option, as a drag sends it. One undo step per press; nothing
  during a drag or with only locked artboards selected.
- tests: arrow_keys_nudge_the_selected_artboards (tools: claims, step,
  Shift, moveArt, several boards, locked, mid-drag) and
  artboard_tool_arrow_keys_move_the_selected_artboards_with_their_art
  (engine: the artboards and the art on them move, one step per press).

Verified live over --control: a 400x300 artboard with a selected 40 pt
square, Artboard tool, ArrowRight: before, the artboard stayed at x 0 and
the square moved to x 51 ("Move"); after, the artboard goes to x 1 and the
square with it ("Move Artboard"); Shift+Down moves both 10 pt; with
Move Artwork off only the artboard moves; increment 5 moves 5 pt.

The drag case in the report: a drag already moves the art, but only the
art that lies wholly inside the artboard and is unlocked and visible
(doc/src/lib.rs art_on_artboard; locked and hidden art only with
Move Locked and Hidden Artwork with Artboard). Art overlapping the
artboard's edge stays behind by design, which may be what the reporter
saw when dragging.
2026-10-22 16:06:54 +07:00
BFlat c49a1faa4b Merge pull request #3542 from nrq6993-a11y/stream/vc-feat3-g4-mixed-justification-dialog
G4: Mixed Justification dialog fields
2026-10-22 15:48:04 +07:00
bflatastic 5498b62a3d G4: Mixed Justification dialog fields
Squashed from #3542 onto main.
2026-10-22 15:35:44 +07:00
BFlat a65d3f2313 Merge pull request #3541 from lehni/fix/stretched-text-redraw 2026-10-22 15:34:38 +07:00
Jürg Lehni 81dca05eb1 Render: moved stretched type redraws where its strokes reach
Follows #3484: characters' strokes stretch with the type, past what its scale factor covers.
2026-10-22 15:29:37 +07:00
Jürg Lehni a2e7a2a562 Render: moved stretched type redraws where its strokes reach
Follows #3484: characters' strokes stretch with the type, past what its scale factor covers.
2026-10-22 10:26:50 +02:00
BFlat 3b8e1aef43 Merge pull request #3539 from nrq6993-a11y/stream/vc-featD-g4-mixed-paragraph-kinsoku-mojikumi
G4: Mixed paragraph kinsoku and mojikumi dropdowns
2026-10-22 15:17:07 +07:00
BFlat 8862058388 Merge pull request #3537 from nrq6993-a11y/stream/vc-featD-g4-mixed-paragraph-hyphenate
G4: Mixed paragraph Hyphenate checkbox
2026-10-22 15:16:56 +07:00
bflatastic b833187ff8 G4: Mixed paragraph kinsoku and mojikumi dropdowns
Squashed from #3539 onto main.
2026-10-22 15:08:51 +07:00
Cursor Agentandnrq6993-a11y 2bf0595f09 G4: mixed paragraph Hyphenate checkbox
## Summary
When the Paragraph and Properties panels act on multiple paragraphs with different hyphenation settings, the Hyphenate checkbox is off, matching Illustrator mixed-value behaviour alongside mixed indents, alignment and composer. Toggling still applies through `text.setFormat` to every paragraph in scope.

## Tests
- `paragraph_mixed_hyphenate_checks_off` in `vectorcraft-ui-egui` paragraph panel tests.

Part of gap G4

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 15:08:31 +07:00
BFlat 388c653cd1 Merge pull request #3534 from nrq6993-a11y/stream/vc-feat1-3505-type-on-path-fill-hit
Type on a Path hits filled paths without a stroke (#3505)
2026-10-22 15:02:46 +07:00
Cursor Agentandnrq6993-a11y 8735170a34 Type tools: hit path outline only for area and path type
## Summary
Adobe help documents Area Type and Type on a Path as starting from a click on the path or shape border, not inside a filled interior. Restore outline-only hit testing for those tools (no fill-interior hits that would steal point-type clicks). Strokeless filled paths still accept clicks on the outline.

## Tests
- area_and_path_tools_hit_path_outline_not_fill_interior

Part of storytold/vectorcraft#3505 (review feedback on PR #3534)

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 15:01:36 +07:00
Cursor Agentandnrq6993-a11y 6883235bad G11: Type on a Path hits filled paths without a stroke (#3505)
## Summary
Area Type and Type on a Path locate the path under the click with the same hit rules as the other tools, so a filled shape with no stroke can be clicked in its interior. Illustrator accepts those clicks for area type and for type on a path (the start follows the click on the path). VectorCraft had forced outline-only hit testing here, so interior clicks missed and the tools fell back to ordinary point or area text with placeholder copy.

## Tests
- type_on_path_and_area_type_hit_filled_paths_without_a_stroke in vectorcraft-tools

Closes storytold/vectorcraft#3505

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 15:01:36 +07:00
BFlat 0414a13ace Merge pull request #3535 from MorningFrog/feat/3515-recent-fonts
Type: show recently used fonts in font pickers (#3515)
2026-10-22 14:43:08 +07:00
BFlat f1dc5e50a3 Merge pull request #3533 from nrq6993-a11y/stream/vc-feat1-3440-color-proxy-drag
G11: Color Picker and popover Fill/Stroke proxy drags (#3440)
2026-10-22 14:42:54 +07:00
MorningFrog 4ae1b3d51a #3515: show recent fonts in font pickers 2026-10-22 14:35:57 +07:00
Cursor Agentandnrq6993-a11y 36eed1ac77 Keep fill/stroke chip popover open during proxy drags
## Summary
The fill/stroke chip popover no longer closes while the pointer is down, a panel paint drag is active, or the opening click is still on the chip anchor, so dragging fill onto stroke (or the reverse) inside the popover can finish. Color Picker proxies are not added; the reference app edits only the colour the picker was opened for.

## Tests
- panels::tests::dropping_a_proxy_paint_on_the_other_proxy_applies_it (unchanged)

Closes storytold/vectorcraft#3440

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 14:35:56 +07:00
Cursor Agentandnrq6993-a11y 5a7689c3b4 G11: Color Picker and popover Fill/Stroke proxy drags (#3440)
## Summary
Illustrator lets you drag the fill colour onto the stroke (or the other way) in the Color Picker and in the fill/stroke chip popover. VectorCraft now shows the Fill/Stroke proxy pair in the Color Picker (except for artboard background edits) and applies paint.setFill or paint.setStroke when a proxy paint is dropped on either square; clicking a square switches which side the picker edits. The fill/stroke chip popover stays open while the pointer is down, while a panel paint drag is active, or when the opening click is on the chip anchor, so drags between the proxies can finish.

## Tests
- color_picker_proxy_drag_applies_fill_to_stroke (headless egui)
- existing dropping_a_proxy_paint_on_the_other_proxy_applies_it unchanged

Closes storytold/vectorcraft#3440

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 14:35:56 +07:00
BFlat af520a7cf3 Merge pull request #3469 from dennisklappe/fix/3410-preferences-storage-label
Rename the Preferences "Performance & Storage" category to "Storage" (#3410)
2026-10-22 14:03:07 +07:00
Dennis Klappe b11b41d5f3 Name the Preferences category Plug-ins & Scratch Disks
Fixes #3410
2026-10-22 14:01:18 +07:00
Dennis Klappe 6ee357a6d4 Rename Preferences category Performance & Storage to Storage
Fixes #3410
2026-10-22 13:54:04 +07:00
BFlat bb4767fca5 Merge pull request #3507 from nrq6993-a11y/stream/vc-feat-g11-arc-tool-dialog
G11: Open Arc tool options on canvas click
2026-10-22 13:50:46 +07:00
Cursor Agent b1befcfa02 G11: translate Arc Segment Tool Options dialog in all catalogs
Co-authored-by: nrq6993-a11y <[email protected]>

# Conflicts:
#	crates/ui-egui/src/i18n/bg.tsv
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/da.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/el.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/fi.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/id.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ka.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sv.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
2026-10-22 13:38:09 +07:00
Cursor Agentandnrq6993-a11y 71b5c9c45c G11: Arc tool keeps segment options and uses them on drag
Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 13:38:04 +07:00
Cursor Agentandnrq6993-a11y 5359c92a19 G11: Arc segment dialog options wired to shape.arc
Replace length/angle with Length X/Y, reference point, Type, Base Along, Slope and Fill Arc; extend geom and shape.arc params.

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 13:38:04 +07:00
Cursor Agentandnrq6993-a11y d6e64cb080 G11: open Arc tool options on canvas click
## Summary
A click on the artboard with the Arc tool (no drag) opens an Arc options
dialog at the pointer, matching Illustrator. Length, angle, and Closed
follow the same form as Line Segment; OK runs shape.arc and remembers
Closed via tool.setOption.

## Tests
- dialogs::tests::arc_click_opens_options_and_ok_draws
- Existing draw2::family::tests::click_opens_dialog_and_arc_shift (tools)

Part of gap G11

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 13:38:04 +07:00
Cursor Agentandnrq6993-a11y fa3d6d9298 G11: open Arc tool options on canvas click (WIP)
Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 13:38:04 +07:00
BFlat 5013189fd3 Merge pull request #3524 from odgrim/fix/2160-x1a-flatten-keeps-cmyk-inks
PDF/X-1a and X-3: flattening keeps a CMYK document's inks where it rasterizes (#2160)
2026-10-22 12:44:27 +07:00
Brian TorresandClaude Opus 5.5 945fa338d0 PDF/X-1a and X-3: flattening keeps a CMYK document's inks where it rasterizes (#2160)
Exporting a CMYK document to PDF/X-1a or PDF/X-3 flattens transparency
first. Where the flattener has no single colour (an image with see-through
pixels over a flat fill, gradients, masks, raster effects) it renders the
area into an image, and that image was always an RGB PNG. The PDF writer
then separated it back to CMYK, so CMYK art behind the image came back with
other inks: a C100 M6 Y41 K3 panel printed C75 M14 Y35 K5 under a
transparent logo, with a hard edge against the rest of the panel, and K
moved into C, M and Y.

For files without transparency the flattener's images are opaque and
clipped to their regions anyway, so in a CMYK document it now draws them
as ink amounts over the paper (Renderer::render_region_inks) and stores a
CMYK TIFF, which the PDF writer keeps as DeviceCMYK samples. An area too
large for a CMYK image falls back to the PNG it made before. In-app
Flatten Transparency and other flattening, whose images keep alpha, are
unchanged.

Test: tests_pdfx::pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document
exports a CMYK panel under a half see-through PNG to PDF/X-1a, opens the
file again and checks the inks behind the see-through half.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:32:05 +07:00
BFlat 5a258e4c73 Merge pull request #3523 from nrq6993-a11y/stream/vc-featD-g4-mixed-paragraph-composer
G4: Mixed paragraph composer menu checks
2026-10-22 12:22:48 +07:00
Cursor Agentandnrq6993-a11y 7fa5173962 G4: mixed paragraph composer menu checks
## Summary
When the Paragraph panel menu acts on multiple paragraphs with different composers (single-line vs every-line), neither composer item is checked, matching Illustrator mixed-value behaviour and the panel's other mixed fields (indents, alignment).

## Tests
- `paragraph_mixed_composer_checks_neither_menu_item` in `vectorcraft-ui-egui` paragraph panel tests.

Part of gap G4

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 12:11:07 +07:00
BFlat 379b033728 Merge pull request #3520 from dotCooCoo/fix/locate-library-checks-the-library-not-persistence
Library panels: Locate Library… reads a missing library whose panel isn't persistent
2026-10-22 11:41:31 +07:00
dotCooCoo de1182fbe3 Library panels: Locate Library… reads a missing library whose panel isn't persistent
ui.libraryPanel.locate decided whether a panel's library was missing
through missing_file, which answers only for a persistent panel. A
command that turns Persistent off leaves the panel open on its missing
library until the next drawn frame closes it, and while the window is
hidden the app draws no frame between control requests. Locate
Library… on that panel answered "isn't missing its library" and read
no file.

The check now asks whether the panel's library can be found, the
lookup the tab label and the per-frame tidy use. A panel whose library
is there is still refused, persistent or not.

window.swatchLibrary and window.graphicStyleLibrary had the same
check: given the library id of such a panel, they answered "no
library". They now bring the open panel forward and answer its file's
name with count 0; the panel still closes at the next tidy.

Docs: docs/mcp.md (ui.libraryPanel.locate, window.swatchLibrary).
2026-10-22 11:34:47 +07:00
BFlat 0834be2989 Merge pull request #3506 from nrq6993-a11y/stream/vc-feat1-3494-package-status-i18n
Fix #3494: translatable package status message
2026-10-22 11:34:23 +07:00
Cursor Agentandnrq6993-a11y fd8459592d Fix #3494: package status spacing without notes
## Summary
Package status notes are parenthetical fragments joined with spaces; templates without {note} are used when there are no missing links or skipped fonts, so folder paths are not followed by stray spaces or double gaps.

## Tests
- package_status_without_notes_has_no_extra_spaces
- package_status_message_templates_parse extended

Closes storytold/vectorcraft#3494

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 11:32:58 +07:00
BFlat ae13bd7dee Merge pull request #3518 from nrq6993-a11y/stream/vc-feat1-3395-acb-tab-filename
Fix #3395: color book library tabs use file name
2026-10-22 11:27:01 +07:00
Cursor Agentandnrq6993-a11y b14fa518fb Fix #3494: translatable package status message
## Summary
Package completion status and the show-folder prompt use i18n templates with
literal text between {folder} and {note}, so @msg rows parse and translate in
every locale (Illustrator-style localized status feedback).

## Tests
package_status_message_templates_parse; dialogs::tests_package; complete_languages_translate_every_message.

Closes storytold/vectorcraft#3494

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 11:22:56 +07:00
Cursor Agentandnrq6993-a11y 3d4d34fc15 Fix #3395: color book library tabs use file name
## Summary
Swatch library panels title imported and User Defined .acb color books from the file name (without the extension), matching Illustrator. Host temporary import paths still fall back to the book title when copying into the user library folder (#2996).

## Tests
- `color_book_tab_titles_use_the_file_name` covers user-folder scan, load by path, and copyToUser keeping the source file name.
- Existing `copying_a_color_book_from_a_temporary_path_uses_its_title` unchanged.

Closes storytold/vectorcraft#3395

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 11:17:31 +07:00
BFlat 15b041be81 Merge pull request #3502 from nrq6993-a11y/stream/vc-feat1-3455-home-reopen-focus
Fix #3455: Home recent reopen focuses existing document
2026-10-22 11:08:42 +07:00
Cursor Agentandnrq6993-a11y e4db022f49 Fix #3455: focus open tab from Home/Open Recent only
## Summary
Home › Recent Files and File › Open Recent switch to an already-open document tab instead of calling document.open again. document.open is unchanged for File › Open, MCP, and save-and-reopen tests.

## Tests
- home_recent_reopen_focuses_an_already_open_document (ui-egui)
- Removed engine open_same_path_activates_existing_document

Closes storytold/vectorcraft#3455

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 11:01:58 +07:00
Cursor Agentandnrq6993-a11y 11eb27b127 Fix #3455: Home recent reopen focuses existing document
## Summary
document.open with a path that is already open in another tab now activates
that document instead of reading the file again and opening a second window,
matching Illustrator Home › Recent Files when the file is already open.

The result includes activated: true; unsaved edits and the active tab are kept.

## Tests
open_same_path_activates_existing_document in cmd::fileio::tests.

Closes storytold/vectorcraft#3455

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 11:01:57 +07:00
BFlat e5b732baf5 Merge pull request #3512 from addibble/feat/ui-egui-ui-fonts-feature
UI: an embedder of the egui panels binds their font families without shipping their faces
2026-10-22 10:54:45 +07:00
BFlat 4137d6d286 Merge pull request #3277 from FractalEncrypt/fix/artboard-options-fit-presets
Add shared presets and artwork fitting to Artboard Options
2026-10-22 10:47:09 +07:00
Drew DibbleandClaude Opus 5.5 317ca00ccc UI: an embedder of the egui panels binds their font families without shipping their faces
The panels and dialogs draw in their own egui families (`ui`,
`ui-semibold`, `mono`), and egui panics drawing text in a family no
font is bound to. Only `theme::install_fonts` binds them, and only
`VectorcraftApp::logic` calls it, on its first frame. An embedder that
draws the panels or dialogs itself (`dialogs::show`, a panel's `ui`)
on its own context, without `logic`, panics on the first semibold
title it draws, an effect dialog's for one. Calling `install_fonts`
avoids that, but it embeds the interface's five faces (about 2 MB)
whatever the embedder wants: today such an embedder keeps them out of
its binary only by never calling it, so that the linker drops them.

vectorcraft-ui-egui gets an `interface-fonts` feature, on by default,
that embeds those faces. Without it `install_fonts` binds the same
three families to egui's built-in fonts, so an embedder can always
call it. Those lack some symbols the panels show (→ ▾ ● ✓), which the
docs say. With the feature, `install_fonts` installs exactly what it
did before: the same font data and the same family stacks.

`tests/install_fonts.rs` draws an effect dialog on a context only
`install_fonts` prepared, and checks the face at the head of each
family in both configurations. `cargo xtask ci`'s `no-bundled-fonts`
step runs it without default features and lints the crate that way.
Without the `install_fonts` call, or without the semibold binding,
both tests fail with egui's "FontFamily::Name(\"ui-semibold\") is not
bound to any fonts". The two lib tests about the interface faces are
adjusted: the graphics-loss test checks a family install_fonts binds,
and the catalogs' glyph coverage test needs `interface-fonts`.
docs/development.md says when an embedder calls `install_fonts`, and
that it replaces the context's font definitions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:46:06 +07:00
bflatastic 438c6194a6 Add shared presets and artwork fitting to Artboard Options
Squashed from #3277 onto main.
2026-10-22 10:40:37 +07:00
BFlat 56b7dc300d Merge pull request #3498 from nrq6993-a11y/stream/vc-feat1-3480-pattern-overlap-preview
Fix #3480: pattern edit preview honors Overlap stacking
2026-10-22 09:33:16 +07:00
Cursor Agentandnrq6993-a11y d792e7b2be Pattern edit preview: two dimmed copy groups around centre tile
Review feedback on #3498: dimmed neighbours share one opacity composite
before the centre tile and one after, preserving overlap paint order
without per-copy compositing.

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 09:23:27 +07:00
Cursor Agentandnrq6993-a11y e87534342b Fix #3480: pattern edit preview honors Overlap stacking
## Summary
In pattern editing mode, preview copies were drawn in row-major order and the
centre tile was always composited on top, so Pattern Options Overlap had no
visible effect when negative spacing made tiles overlap (as in Illustrator).
Preview copies now paint in the same order as tiled pattern fills.

## Tests
- vectorcraft-doc: preview_paint_order_respects_overlap

Closes storytold/vectorcraft#3480

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 09:23:27 +07:00
BFlat db42a68e3f Merge pull request #3501 from C0uki/ja-shortcut-editor
i18n(ja): Keyboard Shortcuts editor and the Print overprint note
2026-10-22 08:52:09 +07:00
Claude 2282122272 i18n(ja): Keyboard Shortcuts editor and the Print overprint note
Add the 13 Japanese rows the Keyboard Shortcuts dialog (File > Keyboard
Shortcuts...) and the Print dialog's overprint note were missing, so they no
longer fall back to English. Wording follows the catalog's existing terms
(初期設定, 競合, ショートカット).

Drafted with AI assistance; native-speaker review welcome.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K5NRZNWfaSLBWVzV8hivyS
2026-10-22 08:50:17 +07:00
BFlat 7c39ce026f Merge pull request #3479 from mockydevs/fix/drag-artwork-across-tabs
Support dragging editable artwork between document tabs
2026-10-22 08:43:28 +07:00
BFlat a98dd3e552 Merge pull request #3500 from dotCooCoo/fix/artboards-rename-keeps-the-selection
Artboards: Rename opens on a selected artboard after a Ctrl/Cmd double-click or a paste
2026-10-22 08:40:58 +07:00
BFlat bf3e6adc50 Merge pull request #3499 from dotCooCoo/fix/path-create-refuses-closed-given-as-text-with-d
Commands: path.create with path data refuses closed given as text, and the grids refuse a count
2026-10-22 08:40:43 +07:00
Don Omondi c43ead202f M1: verify tab drag tool reset and scope clipboard evidence
Exercise Selection-tool busy state before takeover and verify it is idle after tab activation, drop, Escape and outside release. Session::set_active already reconstructs the tool via reset_tool_for_doc_switch, so the reported missing-Up busy state does not reproduce and no additional synthetic release is needed.

Narrow documentation to the internal clipboard asserted by the regressions and correct the stationary-pointer sentence. All 10 cargo xtask ci gates pass.
2026-10-22 04:30:22 +03:00
dotCooCoo 152fc18d9b Artboards: Rename opens on a selected artboard after a Ctrl/Cmd double-click or a paste
With the Artboard tool, a Ctrl/Cmd double-click on the name of an
artboard that was not selected added it with the first click and took
it out again with the second, so the name field renamed that artboard
alone. The panel menu's Rename opened the field on the active artboard
even when it was not selected. Paste with the Artboard tool leaves it
outside the selection: it selects the pasted artboards and keeps the
active one. Rename then renamed the active artboard alone.

The second click of a Ctrl/Cmd double-click now leaves the row's
artboard selected, and the field renames the selection with it. A
Ctrl/Cmd double-click on a selected row works as before. When the
active artboard is selected, Rename opens the field on its row;
otherwise Rename opens the field on the first selected row.

Docs: docs/target-app-parity.md.
2026-10-22 08:29:28 +07:00
dotCooCoo bfc3f5251a Commands: path.create with path data refuses closed given as text, and the grids refuse a count
path.create read closed only when it drew from anchors. Given path
data, {"d": "M0 0 L10 0 L10 10", "closed": "true"} drew the path and
recorded an undo step, while the same closed with anchors fails with
"`closed` must be true or false". shape.rectangularGrid and
shape.polarGrid read rows, columns, concentric and radial given as text
as the count left out, so {"rows": "3"} drew the default 5 horizontal
dividers instead of 3.

path.create now checks closed before it reads d or anchors, and the
grid commands refuse such a count with "`rows` must be a number, not
"3"". Neither command changes the document or its undo history then.
With d, a closed of true, false or null still leaves the path as its
path data draws it.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-22 08:24:09 +07:00
Don Omondi f639f449d7 M1: merge main and complete Turkish artwork drag translations
Add the three Turkish catalog entries requested by the maintainer on #3479. Preserve upstream catalog additions and place existing tab-drag entries beside stable menu labels to avoid append conflicts. Update feature progress notes for all 24 catalogs.

Simplify an equivalent Effect Gallery selection predicate required by the Rust 1.95 clippy gate. Full cargo xtask ci passed all 10 checks, including tab-drag regressions and wasm.
2026-10-22 04:16:24 +03:00
BFlat aef00190ed Merge pull request #3493 from nrq6993-a11y/stream/vc-feat1-3465-ase-library-picker
Fix #3465: Swatch Libraries Other Library lists .ase on Linux
2026-10-22 07:39:07 +07:00
BFlat 3dd8c3ec28 Merge pull request #3492 from nrq6993-a11y/stream/vc-feat1-g11-proxy-paint-drop-3467
G11: Copy fill/stroke by dragging between proxies (#3467)
2026-10-22 07:38:52 +07:00
BFlat f3863ef50f Merge pull request #2581 from Muratv07/feature/turkish-localization
i18n: add Turkish (tr) localization
2026-10-22 07:38:37 +07:00
BFlat 6a4a739178 Merge pull request #3491 from dotCooCoo/fix/tests-expand-reply-names-every-object
Tests: the Expand Appearance reply test fails when the reply leaves out the expanded object (#3374)
2026-10-22 07:38:20 +07:00
Cursor Agentandnrq6993-a11y 1fbec667ec Fix #3465: Swatch Libraries Other Library lists .ase on Linux
## Summary
Linux file portals grey out files that do not match the open dialog's default
filter. Other Library… and Locate Library… used File › Open filters, whose
first entry is All readable files and omits swatch-library extensions such as
.ase. Add library_open_filters with the library formats first (matching
Illustrator's Swatch Libraries › Other Library…) and use it from swatch and
graphic style library pickers.

## Tests
- engine: library_open_filters_list_library_formats_first
- ui-egui: other_swatch_library_asks_off_the_ui_thread asserts Swatch libraries
  is the default filter and includes ase

Closes storytold/vectorcraft#3465

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 07:21:53 +07:00
Cursor Agentandnrq6993-a11y 1388b8ec1a G11: clippy collapse in proxy_paint_drop
Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 07:21:52 +07:00
Cursor Agentandnrq6993-a11y fe9ceb3dfa G11: copy fill/stroke by dragging between proxies (#3467)
## Summary
Dragging the Fill or Stroke proxy onto the other square applies that paint
there, like dropping a swatch on art. Matches Illustrator's fill/stroke
chips accepting a dragged color from the other.

## Tests
- panels::tests::dropping_a_proxy_paint_on_the_other_proxy_applies_it

Closes storytold/vectorcraft#3467

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 07:21:52 +07:00
Murat Vural e33094e11d i18n: add Turkish (tr) localization
- Add crates/ui-egui/src/i18n/tr.tsv covering all menu strings, commands, UI labels, and status/error messages with standard Turkish vector software vocabulary.
- Register Turkish in LANGUAGES with plural_none and complete_menus: true.
- Support tr, tr-TR, tr_TR.UTF-8, tr-CY locale mapping in lang_from_tag.
- Add Turkish typographic quotation marks in crates/doc/src/setup.rs.
- Add turkish_reads_as_turkish unit test and register tr in KEEPS_MENU_NAMES and COMPLETE_MESSAGES.
- Include RTF diagnostic message with exact TSV escaping.
2026-10-22 07:21:51 +07:00
Don Omondi 633ad1d749 M1: keep tab drag current and isolate feature progress notes 2026-10-22 03:19:24 +03:00
BFlat f832625e0b Merge pull request #3490 from dotCooCoo/fix/library-panel-locate-refuses-a-panel-that-has-its-library
Library panels: Locate Library… refuses a panel whose library is there
2026-10-22 07:13:54 +07:00
BFlat 2dfe6613ad Merge pull request #2883 from nrq6993-a11y/stream/vc-feat-2434-effect-gallery
ui: Effect Gallery dialog (browse raster filters and apply a stack)
2026-10-22 07:13:32 +07:00
BFlat 9219e41440 Merge pull request #3484 from lehni/fix/mitred-text-redraw
Render: moved type with mitred strokes redraws where its spikes reach
2026-10-22 07:13:16 +07:00
Don Omondi 490c8da696 M1: reconcile tab drag with upstream and document transfer contracts 2026-10-22 03:08:30 +03:00
Murat Vural 9bd9f570ae i18n: size Document Setup Edit Artboards button to fit localized labels 2026-10-22 07:04:00 +07:00
dotCooCoo 66655c67c2 Tests: the Expand Appearance reply test fails when the reply leaves out the expanded object (#3374)
the_reply_names_each_expanded_copy_by_its_own_id checked that no id in
the effect.expandAppearance reply repeats and that each one names a
node in the document. Only for group copies did it check which ids the
reply holds. In the other four cases a reply that left out the
expanded object, or held no ids at all, passed.

The test now checks in every case that the reply starts with the id of
the selected object, and keeps the exact check of the group and the
two copies of the square for group copies.
2026-10-22 07:03:54 +07:00
dotCooCoo 17fbf6ad6a Library panels: Locate Library… refuses a panel whose library is there
ui.libraryPanel.locate checked only that the panel was open. Run on an
open panel whose library was there, such as the built-in Pastels, it
loaded the file given or picked and showed that library in the panel
in place of Pastels.

It now answers "library panel `{given}` isn't missing its library" for
such a panel, before it asks for a file or loads one, and the panel
keeps its library. A persistent panel whose library's file is gone
locates as before.

Docs: docs/mcp.md (ui.libraryPanel.locate).
2026-10-22 07:03:20 +07:00
Cursor Agentandnrq6993-a11y 757eaf9afa Effect Gallery: plain filter list and live stack preview
Remove hash-coloured thumbnails; preview the selection with the applied
filter stack on the canvas (Cancel rolls back). OK commits via batch.

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 07:03:19 +07:00
nrq6993-a11yandCursor d275fbf725 G2: catalog Effect Gallery dialog tl! strings for all locales.
Co-authored-by: Cursor <[email protected]>
2026-10-22 06:52:12 +07:00
nrq6993-a11yandCursor e72735bcf9 G2: satisfy clippy and rustfmt on Effect Gallery dialog.
Co-authored-by: Cursor <[email protected]>
2026-10-22 06:52:07 +07:00
nrq6993-a11yandCursor dd9c285252 G2: Effect Gallery six categories and per-filter settings
Limit gallery folders to the six Photoshop-style sets and edit stack params via the existing effect dialog body before apply.

Co-authored-by: Cursor <[email protected]>
2026-10-22 06:52:07 +07:00
nrq6993-a11yandCursor ae3a8fe4c3 G2: Effect Gallery dialog to browse and apply raster filter stacks
Adds Effect → Effect Gallery with category browsing, procedural thumbnails, and a reorderable stack applied via command.batch as separate appearance effects.

Co-authored-by: Cursor <[email protected]>
2026-10-22 06:52:07 +07:00
Jürg Lehni 60f4c4f145 Render: moved type with mitred strokes redraws where its spikes reach
Follows #3461: character strokes' miter spikes reach past half the weight, which the redrawn area missed.
2026-10-22 06:52:06 +07:00
BFlat 79dca4c8f9 Merge pull request #3488 from KaboomFox/export-dialog-guard
Export As: a held Enter doesn't confirm the options; exporters get their file
2026-10-22 06:50:50 +07:00
BFlat afd66ad695 Merge pull request #3487 from dotCooCoo/fix/transform-each-refuses-switches-given-as-text
Commands: Transform Each, artboards and more refuse a boolean or number given as text
2026-10-22 06:50:35 +07:00
Louise FoxandClaude Opus 5.5 2f893566ee Export As: a held Enter doesn't confirm the options; exporters get their file
Follow-ups to the review of the exporter plug-ins PR (#3171):

- Export As opens its format's options once the save panel has picked
  the file. The Enter that closed the panel, still held, confirmed
  them at once: Save As marks such a dialog (dialogs::AFTER_PANEL) so
  it waits for Enter to be let go, Export As didn't. Every options
  dialog it opens after the panel is marked now, an exporter plug-in's
  included, through one helper (dialogs::opened_by_panel) that Save As
  uses too.
- io::export with options that aren't a JSON object dropped the path
  and format it adds for the encoder: an exporter plug-in was told the
  document's name and its first extension instead of the file it
  writes. Such options now count as none.
- The exporter UI tests remove the plug-ins they install, so no other
  test's Export As lists them.

tests_export.rs reproduces the held Enter for every options dialog
(PNG, SVG, Save PDF, Text, DXF, EPS and an exporter's), and
tests_plugin_export.rs the options that aren't an object; both failed
before the fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-22 06:40:28 +07:00
dotCooCoo caa6962508 Commands: Transform Each, artboards and more refuse a boolean or number given as text
object.transformEach refused reflectX, reflectY, random and copy given
as text, but read strokes, corners, objects and patterns given as text
as the key left out. With Scale Strokes & Effects on,
{"scaleH": 200, "scaleV": 200, "strokes": "false"} scaled the stroke,
recorded an undo step and kept the params for Transform Again, which
repeated the call. artboard.rearrange, artboard.move and
artboard.duplicate read their patterns option the same way, and
artboard.setProps reads its patterns, strokes and corners options the
same way.

These commands now refuse such a value with "`<key>` must be true or
false" before they change anything. So do the other options and
numbers of artboard.setProps and artboard.move, the art of Duplicate,
Copy and Cut Artboard, the shape commands and path.create, path.close,
path.setHandle, path.moveAnchors, the Pencil, Curvature, Reshape
Segment and Blob Brush options, text.create, text.createInPath,
text.setRangeStyle, text.editRange, text.getRange, text.touchType,
text.areaOptions, type.step, the font size steps,
type.opticalMarginAlignment, type.systemLayout, type.fractionalWidths,
type.ruby, the symbol and swatch commands, the Appearance panel's
commands, the gradient and freeform gradient commands, Shape Builder,
Live Paint, Image Trace, the opacity of object.setProps, the radius,
sides and relativeRadii of object.setLiveShape, the reference point of
object.setBounds and the artboard of Align, Distribute and Distribute
Spacing. They refuse a number given as text with "`<key>` must be a
number, not <value>", and a gradient stop's tint with "stop <n>:
`tint` must be a number". A null still reads as the key left out.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-22 06:40:27 +07:00
Dylan M. Taylor f64db69b53 Use chrono instead of hand-rolled UTC conversion (#3387)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled Howard Hinnant civil/days-from-civil copies and the
leap-year/days-in-month tables with chrono as a workspace dependency:
- apps/vectorcraft/src/logging.rs: timestamp (log line time)
- crates/doc/src/metadata.rs: civil (Unix seconds -> [y,m,d,h,mi,s]) and iso8601
- crates/pdf/src/file_info.rs: unix (date -> seconds) and in_month, used by the
  PDF/XMP date parsers (pdf_date, xmp_date)
Output formats and parsing semantics are unchanged and pinned by the existing
tests (PDF D:... and XMP dates, offsets, leap-year rejection, year clamp 0-9999,
pre-epoch clamp). chrono uses only std, so wasm stays safe. AGENTS.md/CLAUDE.md
now say time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. timestamp now uses the fallible DateTime::from_timestamp
and reads such a clock (or a pre-epoch one) as the epoch instead. Years past
9999 are written with {:04} like before, because %Y would add a '+'. Tests
cover the out-of-range and 5-digit-year cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve old out-of-range date conversion (no epoch fallback)

`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any instant
past chrono's year range to 1970-01-01. Restore the old conversion domain with
a cycle-based helper that routes the calendar through chrono (the calendar
repeats every 400 years = 146_097 days, so month/day/time come from chrono and
only the year is carried in i128, keeping every i64 exact).

- apps/vectorcraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
  pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/doc/src/metadata.rs: `civil` cycle-based, keeping its clamp EXACTLY
  as the old code had it: the input is clamped to 0000-01-01..9999-12-31 first
  and the year is clamped to 0..=9999 afterwards. (With the input clamp the old
  `unwrap_or_default()` was already unreachable; this drops it and makes the
  conversion explicit, with identical output.)

Pinned old outputs in tests:
  logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch),
          10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  metadata civil(i64::MIN)[0]=0, civil(i64::MAX)[0]=9999;
          iso8601(i64::MIN)=0000-01-01T00:00:00Z,
          iso8601(i64::MAX)=9999-12-31T23:59:59Z (year clamp, as before)

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-21 19:36:32 -04:00
Dylan M. Taylor 2b8a11876a security: enforce HTTPS on curl downloads (SonarCloud S6506) (#3475)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 19:35:52 -04:00
BFlat dc9d751015 Merge pull request #3468 from nrq6993-a11y/stream/vc-featD-3451-ellipse-pie-properties
Ellipse pie angles in Transform and Properties (#3451)
2026-10-22 06:35:19 +07:00
Cursor Agentandnrq6993-a11y 6066b4b8c7 Properties: regression test for ellipse pie in Transform section (#3451)
Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 06:29:30 +07:00
Cursor Agentandnrq6993-a11y 718c65c855 Ellipse pie angles in Transform and Properties (#3451)
## Summary

Live ellipses already support pie start/end angles, canvas widgets and Invert Pie
like Illustrator. Label the Properties panel Transform section with Ellipse
Properties so the pie fields match the Transform panel and are easier to find.

## Tests

- `an_ellipse_shows_its_pie_properties` in `panels/transform` tests.

Closes storytold/vectorcraft#3451

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 06:29:30 +07:00
BFlat a4f5ae3b16 Merge pull request #3482 from dotCooCoo/fix/vector-exports-one-decode-note
Export: EPS, legacy .ai, EMF/WMF and DXF give one note for images that can't be decoded
2026-10-22 06:22:29 +07:00
BFlat de917a138a Merge pull request #3486 from dotCooCoo/fix/tests-data-set-export-fails-on-a-stale-folder
Tests: the data set export test fails when its folder cannot be emptied (#3389)
2026-10-22 06:22:14 +07:00
dotCooCoo e66bca39b2 Export: legacy .ai gives the size note for an image with empty data
An image whose data is empty failed to decode before the size check, so a
version 7 .ai said it couldn't be decoded. The writer now checks for empty
data first and gives "an image that is empty or too large for legacy .ai
was left out". docs/mcp.md now says the DXF decode note applies to R13 and
later; R12 leaves out every image with its own note.
2026-10-22 06:13:32 +07:00
dotCooCoo 4e4723699d Export: EPS, legacy .ai, EMF/WMF and DXF give one note for images that can't be decoded
EPS, EMF, WMF and DXF exports gave each image they left out because it
couldn't be decoded its own warning, "image '<key>' could not be
decoded and was left out", which names the internal key the document
stores the image under. A version 7 .ai said "an image that can't be
decoded or is too large was left out".

The four writers now give the note raster exports, PDF and the legacy
.ai flattening give, "an image couldn't be decoded and is missing from
the picture", once per export. In a version 7 .ai it is one line with
the note for flattened art. The other image notes in the same
functions no longer name the key: EPS's note for an image too large to
write, and DXF's notes for an image with no pixels (its rows in 23
catalogs are reworded) and one that could not be written. Legacy .ai
gives a separate note for an image that is empty or too large.

Docs: docs/mcp.md (EPS, DXF export, EMF and WMF, aiVersion "7").
2026-10-22 06:13:32 +07:00
dotCooCoo 3bb0f07a4b Tests: the data set export test fails when its folder cannot be emptied (#3389)
variables_panel_data_sets_ask_off_the_ui_thread empties its folder
before Export Data Sets… writes out.csv, then checks that the file is
exactly the header and the row for data set B. It ignored the result
of remove_dir_all, and create_dir_all succeeds on a folder that is
still there. When the folder could not be emptied, an out.csv left by
an earlier run passed the check even when the export wrote nothing.

The test now fails when emptying the folder fails with an error other
than NotFound.
2026-10-22 06:07:55 +07:00
BFlat 92a8a45d32 Merge pull request #3464 from dotCooCoo/fix/pdf-14-writes-16-bit-images-at-8
PDF export: a 16-bit PNG is written at 8 bits for PDF 1.3 and 1.4 instead of failing the export
2026-10-22 06:07:26 +07:00
dotCooCoo 5f6c7b8da9 PDF export: a 16-bit PNG is written at 8 bits for PDF 1.3 and 1.4 instead of failing the export
When the Compression settings keep a PNG as it is, the PNG goes to
the PDF writer unchanged, and the writer refuses images of 16 bits a
sample below PDF 1.5. Saving a document with a placed 16-bit PNG as
PDF 1.4, or as a .ai file at 1.4, failed with "PDF writer error:
SixteenBitImage(...)" and wrote nothing. PDF 1.3 and PDF/X-3 failed
the same way for an opaque 16-bit PNG, and PDF/X-1a failed for a
16-bit gray PNG.

In PDF 1.3 and 1.4 files, such a PNG is now decoded and encoded again
at 8 bits a sample, with the same color type and ICC profile, before
the writer reads it. From PDF 1.5 on, it keeps its 16 bits. A 16-bit
PNG whose pixel data can't be decoded is left out with the note "an
image couldn't be decoded and is missing from the picture".

Docs: docs/mcp.md (PDF options).
2026-10-22 05:57:11 +07:00
BFlat 14a70449f7 Merge pull request #3483 from nrq6993-a11y/stream/vc-feat1-g11-guide-align-artboard-3463
G11: Align panel moves ruler guides to artboard (#3463)
2026-10-22 05:28:22 +07:00
Cursor Agentandnrq6993-a11y c8082facb1 G11: Align panel moves ruler guides to artboard (#3463)
## Summary
object.align now moves selected ruler guides when aligning to the artboard,
matching Illustrator and the existing Distribute behaviour. Guide-only
selection can run align; the Align panel enables its buttons when only
guides are selected. Guides still stay put when objects are in the selection
and Align To is Selection; key-guide behaviour is unchanged.

## Tests
- engine tests_keyguide: guides_selected_alone_align_to_artboard; updated
  selected_guides_align_to_a_clicked_key_object for artboard align
- ui-egui align panel: the_align_buttons_align_guides_selected_alone_to_artboard

Closes storytold/vectorcraft#3463

Co-authored-by: nrq6993-a11y <[email protected]>
2026-10-22 05:20:10 +07:00
BFlat 430741f074 Merge pull request #3481 from dotCooCoo/fix/save-as-extension-compare-ignores-case
Save As: the suggested extension typed in another case gets the picked type's
2026-10-22 05:19:59 +07:00
dotCooCoo bd9a508d49 Save As: the suggested extension typed in another case gets the picked type's
On Linux and the BSDs, where the save dialog reports the file type
picked in it, a name that still ends in the suggested name's extension
gets the picked type's extension. The comparison was case-sensitive,
so Untitled-1.VECTORCRAFT with "PDF-compatible .ai" picked was saved
as a native file named Untitled-1.VECTORCRAFT instead of Untitled-1.ai.

with_picked_type now compares the lower-case extensions that
fileio::extension gives. A name typed with the picked type's own
extension in another case (a.AI with .ai picked) stays as typed.

This follows up #3372.

Docs: docs/target-app-parity.md.
2026-10-22 05:09:55 +07:00
BFlat b15230bfb5 Merge pull request #3461 from lehni/update/canvas-partial-redraw
Canvas: an edit redraws only the area it changed
2026-10-22 05:09:38 +07:00
BFlat 15434b6db4 Merge pull request #2539 from vangel76/feat/1124-blend-easing
Blend: Ease In / Ease Out and separate Color Ease in Blend Options (#1124)
2026-10-22 04:50:42 +07:00
Don Omondi 84e76c1d70 M1: support dragging editable artwork between document tabs
Activate visible tabs after a delayed Selection drag and copy artwork through a document-scoped command without changing the clipboard or source. Cancel stale transfers, preserve pattern alignment and transparency, and insert into the editable destination context. Cover pointer gestures, resource rendering, input bounds and locked layers; translate the new labels and messages.
2026-10-22 00:48:57 +03:00
Jürg Lehni ede08590a0 Canvas: an edit redraws only the area it changed
Wide effect rasters cropped to the frame also serve parts of it.
2026-10-22 04:45:43 +07:00
bflatastic 92f295d2a2 Blend: Ease In / Ease Out and separate Color Ease in Blend Options (#1124)
Squashed from #2539 onto main.
2026-10-22 04:40:10 +07:00
BFlat cc42a333b7 Merge pull request #3477 from dotCooCoo/fix/ruby-tests-with-ci-fonts
Type: the vertical ruby test passes with craft-fonts' Shippori Mincho
2026-10-22 04:39:15 +07:00
dotCooCoo 95f503367f Type: the vertical ruby test passes with craft-fonts' Shippori Mincho
tests_ruby::vertical_ruby_is_upright_to_the_right_of_the_column
checked that no reading glyph is more than 1 pt wider than tall. With
craft-fonts in the build, the kana come from Shippori Mincho, whose か
is 8.24 pt wide and 7.22 pt tall at 10 pt. The test failed on that
glyph although it stands upright. FreeBSD CI, which builds with
craft-fonts at a71c20e3, fails it on every branch since 14f804cd. It
also fails on Windows with that ref or an older craft-fonts checkout.

The test now compares each reading glyph's ink with its own outline at
10 pt. The same width and height mean the glyph is upright; a swapped
width and height mean it lies on its side. It also requires one
reading glyph whose outline is not square.
2026-10-22 04:34:32 +07:00
BFlat a339433c4b Merge pull request #3436 from dotCooCoo/feat/character-direction
Character: Character Direction sets the selected characters left to right or right to left
2026-10-22 04:28:48 +07:00
bflatastic 34cde7c3db Character: Character Direction sets the selected characters left to right or right to left
Squashed from #3436 onto main.
2026-10-22 04:18:35 +07:00
BFlat 0ac1f133b0 Merge pull request #3281 from gorkematlyn/upstream-ui8
UI.8: collapse dock groups to their tabs
2026-10-22 04:13:54 +07:00
bflatastic b3268a829d UI.8: collapse dock groups to their tabs
Squashed from #3281 onto main.
2026-10-22 04:07:25 +07:00
355 changed files with 27048 additions and 2068 deletions

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
+1
View File
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
Generated
+12
View File
@@ -712,6 +712,15 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@@ -4569,6 +4578,7 @@ version = "0.14.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"block2 0.6.2", "block2 0.6.2",
"chrono",
"clipboard-win", "clipboard-win",
"craft-print", "craft-print",
"craft-window", "craft-window",
@@ -4678,6 +4688,7 @@ dependencies = [
name = "vectorcraft-doc" name = "vectorcraft-doc"
version = "0.14.0" version = "0.14.0"
dependencies = [ dependencies = [
"chrono",
"hayro-ccitt", "hayro-ccitt",
"image", "image",
"jpeg-encoder", "jpeg-encoder",
@@ -4846,6 +4857,7 @@ name = "vectorcraft-pdf"
version = "0.14.0" version = "0.14.0"
dependencies = [ dependencies = [
"aes", "aes",
"chrono",
"craft-pdf", "craft-pdf",
"flate2", "flate2",
"image", "image",
+2
View File
@@ -56,6 +56,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] } serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] } serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4" log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1" proptest = "1"
crc32fast = "1.5" crc32fast = "1.5"
# tests: a TCP port bound without listening (connections to it are refused) # tests: a TCP port bound without listening (connections to it are refused)
+8 -3
View File
@@ -8,6 +8,10 @@
</p> </p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png" width="128" height="128" alt="VectorCraft icon: an engraved dragon on a red tile">
</p>
<h1 align="center">VectorCraft</h1> <h1 align="center">VectorCraft</h1>
<p align="center"> <p align="center">
@@ -238,8 +242,9 @@ Pathfinder and Shape Builder, paint, gradients, appearance and transparency, typ
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft pages, shadows, glows, outlines and overlays, embedded Affinity documents and images, with what didn't come in
(adjustments, blurs, bevels, brushes…) listed in the import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs. and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md). [Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
@@ -253,7 +258,7 @@ The scores are self-assessed from our code against public documentation;
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written); - reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours; - an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- most of the raster effects (9 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges and Water Paper under Sketch) and the Effect Gallery; - the Effect Gallery (all 57 Photoshop-style raster filters are available, approximated, under Artistic, Blur, Brush Strokes, Distort, Pixelate, Sharpen, Sketch, Stylize, Texture and Video);
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- scripting, and the rest of Variables (image and graph kinds, dataset import); - scripting, and the rest of Variables (image and graph kinds, dataset import);
- generative AI features. - generative AI features.
+11 -4
View File
@@ -9,7 +9,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away **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-22 · **Change:** minor (ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; headline numbers unchanged; previously:; Affinity import AF.5: master pages, tables, paragraph formats, shadows, glows, outlines, overlays and embedded documents; ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range, above or below (right of the column in vertical type), aligned, sized and overhanging or squeezed; saved natively, laid out with line growth, drawn on the canvas and in SVG/PDF (outlines where ruby is present); headline numbers unchanged; previously:; 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged; Print: Japanese paper sizes in the Print dialog's Media list — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (which keep their ISO 216 sizes), はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判; about 90 community PRs of 2026-10-19: Objects on Path, the Simplify slider, Pencil Edit Selected Paths in the middle of a path, Georgian, and more; Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; 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 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 on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind | | | Value | Kind |
|---|---|---| |---|---|---|
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 54/57, 3D 4/4 (initial types, no materials) | | **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
| **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated | | **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated |
@@ -76,7 +76,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
| Strokes, brushes, width profiles | 88% | 8–15 | | Strokes, brushes, width profiles | 88% | 8–15 |
| Appearance, transparency, graphic styles, masks | 97% | 1–2 | | Appearance, transparency, graphic styles, masks | 97% | 1–2 |
| Live vector effects | 85% | 6–10 | | Live vector effects | 85% | 6–10 |
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 82% | 8–14 | | Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 82% | 4–6 |
| 3D and Materials | ~25% | 30–55 | | 3D and Materials | ~25% | 30–55 |
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 | | Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 | | Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
@@ -123,12 +123,16 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h. 2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
3. **Launch and stability on real machines** (G10): 20–35 h. 3. **Launch and stability on real machines** (G10): 20–35 h.
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h. 4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
5. **Raster effects** (G2) alongside: the 10 remaining filters (7 Artistic, 2 Sketch, 1 more) and the Effect Gallery, 8–14 h. 5. **Raster effects** (G2) alongside: the Effect Gallery (every Photoshop-style filter is in), 4–6 h.
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). 6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
## Progress log ## Progress log
- 2026-10-22: Colour emoji fonts with bitmap glyphs draw and export: `sbix` (Apple Color Emoji, macOS's emoji font) and `CBDT` (bitmap Noto Color Emoji) glyphs show as their pictures on the canvas, whatever the type's fill, scale and turn with the type, and export to PDF (in the font's Type 3 glyphs, or as images), SVG (images when type is outlined; live text leaves emoji to the viewer), EPS and legacy `.ai` (images); Create Outlines turns them into embedded images. Fonts with bitmaps but no outlines, such as Noto Color Emoji, are now found among the installed fonts. COLR and OpenType SVG fonts remain; headline numbers unchanged.
- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged. - 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
- 2026-10-22: PDF accessibility: titled exports set `/ViewerPreferences << /DisplayDocTitle true >>`, so viewers show the document title rather than the file name and title checks can pass (#3646). Headline numbers unchanged.
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. - 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
@@ -142,6 +146,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. | | 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. |
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). | | 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
| 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. | | 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. |
| 2026-10-21 | Affinity import (AF.5), brought over from DesignCraft's (storytold/designcraft#291): master pages show on the pages that use them (a locked layer at the bottom); tables open as a text frame per cell and a line per edge; each paragraph keeps its alignment, indents, spacing and leading; frame text takes its text scale, not its frame's; drop shadows and outer glows become live effects, outlines strokes, colour and gradient overlays are baked into the paint; embedded Affinity documents open as editable content, and their artboards no longer become artboards; large pixel layers come in at a reduced resolution; text placed as Affinity places it (lines of differently aligned paragraphs, a frame's first baseline at cap height, last lines whose descenders hang out of the frame), underlines and strikethrough, line breaks inside paragraphs, table cells taller than their row, no stroke on shapes whose stroke style is None, CMYK pixel layers (phone photos in CMYK documents) shown through the document's own CMYK profile instead of the profile-free formula and its blue cast, outlines on stroked shapes. Headline numbers unchanged. Hostile files stay bounded (one budget for the whole read, embedded documents included; overlay work; master page copies), covered by the import fuzzer and limit tests. |
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. | | 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated | 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5). | 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
@@ -189,6 +194,8 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-22 | minor | Progress log row for colour emoji fonts with bitmap glyphs; headline numbers unchanged. |
| 2026-10-22 | minor | Effects: Sketch › Conté Crayon; measured Photoshop-style filters 57/57, the Effect Gallery remains (G2 estimate 4–6 h). |
| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | | 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged |
| 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. | | 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. |
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. | | 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
+124 -20
View File
@@ -1,8 +1,8 @@
//! `vectorcraft-cli`: VectorCraft from the command line. //! `vectorcraft-cli`: VectorCraft from the command line.
//! //!
//! ```text //! ```text
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR] //! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2] //! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands //! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] //! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli info FILE //! vectorcraft-cli info FILE
@@ -68,19 +68,24 @@ const USAGE: &str = "\
vectorcraft-cli — VectorCraft automation vectorcraft-cli — VectorCraft automation
USAGE: USAGE:
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR] vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979 Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
(vectorcraft --control 7979), falling back to a headless in-process session. (vectorcraft --control 7979), falling back to a headless in-process session.
--automation-read-root / --automation-write-root confine the files the agent's commands read --automation-read-root / --automation-write-root confine the files the agent's commands read
(open, place, relink, library loads…) and write (save, export, package, library saves…) to (open, place, relink, library loads…) and write (save, export, package, library saves…) to
DIR, links followed; a root left out grants none of its access. They imply --headless: a DIR, links followed; each may be given more than once, a path inside any of its folders being
running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …). allowed, and a kind left out grants none of its access. They imply --headless: a running app
enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N] vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N]
Headless batch: open FILE (any readable format) or start a new document, run commands in Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step. (pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
--commands reads steps from FILE, or from standard input for -, where it stands among the
--cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
\"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
Errors name the step, counting from 0 as \"$N\" references do.
vectorcraft-cli commands vectorcraft-cli commands
Print the command catalogue as JSON. Print the command catalogue as JSON.
@@ -177,7 +182,7 @@ fn with_stores(mut h: Headless) -> Headless {
fn mcp(args: &[String]) -> Result<(), String> { fn mcp(args: &[String]) -> Result<(), String> {
let mut connect: Option<String> = None; let mut connect: Option<String> = None;
let mut headless = false; let mut headless = false;
let (mut read_root, mut write_root): (Option<String>, Option<String>) = (None, None); let (mut read_roots, mut write_roots): (Vec<String>, Vec<String>) = (vec![], vec![]);
let mut it = args.iter(); let mut it = args.iter();
while let Some(a) = it.next() { while let Some(a) = it.next() {
// The roots also come as `--flag=DIR`, as the other Craft apps take them. // The roots also come as `--flag=DIR`, as the other Craft apps take them.
@@ -190,10 +195,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
"--headless" => headless = true, "--headless" => headless = true,
"--automation-read-root" | "--automation-write-root" => { "--automation-read-root" | "--automation-write-root" => {
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?; let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root }; if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) }
if slot.replace(dir).is_some() {
return Err(format!("{flag} is given twice: it takes one folder"));
}
} }
other => return Err(format!("unknown mcp option `{other}`")), other => return Err(format!("unknown mcp option `{other}`")),
} }
@@ -201,7 +203,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
if headless && connect.is_some() { if headless && connect.is_some() {
return Err("use either --connect or --headless".into()); return Err("use either --connect or --headless".into());
} }
let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?; let read: Vec<&Path> = read_roots.iter().map(Path::new).collect();
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
let roots = AutomationRoots::new(&read, &write)?;
if roots.is_some() && connect.is_some() { if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`" return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
.into()); .into());
@@ -209,8 +213,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
let backend: Box<dyn Backend> = if let Some(roots) = roots { let backend: Box<dyn Backend> = if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots. // Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone()); file_access::confine_process(roots.clone());
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
Box::new(Headless::with_document().with_automation_roots(Some(roots))) Box::new(Headless::with_document().with_automation_roots(Some(roots)))
} else if headless { } else if headless {
Box::new(with_stores(Headless::with_document())) Box::new(with_stores(Headless::with_document()))
@@ -338,28 +341,127 @@ enum Step {
Export(String), Export(String),
} }
/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
/// Most steps one `run --commands` takes.
const MAX_COMMANDS_STEPS: usize = 100_000;
/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
/// [`MAX_COMMANDS_BYTES`].
fn read_commands(source: &str) -> Result<String, String> {
use std::io::Read;
let mut bytes = vec![];
let read = if source == "-" {
std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
} else {
std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
};
read.map_err(|e| format!("--commands {source}: {e}"))?;
if bytes.len() as u64 > MAX_COMMANDS_BYTES {
return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
}
String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
}
/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
/// {…}}` objects (`"id"` may stand for `"command"`, as in MCP's `command_batch`), or one such object
/// per line (JSON Lines, blank lines skipped). `first` is the run's index of the first of them
/// (the `N` of `"$N"`), which an error names with the entry's place in the text.
fn parse_commands(source: &str, text: &str, first: usize) -> Result<Vec<Step>, String> {
// Notepad on Windows starts UTF-8 files with a byte order mark.
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
// Each entry with where it is in the text: its line for JSON Lines.
let mut entries: Vec<(Option<usize>, Value)> = vec![];
if text.trim_start().starts_with('[') {
let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
if items.len() > MAX_COMMANDS_STEPS {
return Err(too_many());
}
entries.extend(items.into_iter().map(|v| (None, v)));
} else {
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
if entries.len() == MAX_COMMANDS_STEPS {
return Err(too_many());
}
let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
entries.push((Some(n + 1), v));
}
}
entries
.into_iter()
.enumerate()
.map(|(k, (line, v))| {
command_step(v).map_err(|e| {
let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
format!("--commands {source}: step {} ({place}): {e}", first + k)
})
})
.collect()
}
/// The step of one `--commands` entry, `{"command": ID, "params": {…}}`.
fn command_step(v: Value) -> Result<Step, String> {
let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"select.all\", \"params\": {}}".into()) };
let id = match (o.remove("command"), o.remove("id")) {
(Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
(Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
(None, None) => return Err("no `command`".into()),
_ => return Err("`command` must be a string, a command id".into()),
};
let params = match o.remove("params") {
None | Some(Value::Null) => json!({}),
Some(p @ Value::Object(_)) => p,
Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
};
if let Some(key) = o.keys().next() {
return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
}
Ok(Step::Cmd(id, params))
}
fn run(args: &[String]) -> Result<(), String> { fn run(args: &[String]) -> Result<(), String> {
let mut input: Option<String> = None; let mut input: Option<String> = None;
let mut steps: Vec<Step> = vec![]; let mut steps: Vec<Step> = vec![];
// The step of the `--cmd` just given, which a `--params` sets.
let mut params_for: Option<usize> = None;
let mut stdin_read = false;
let mut scale = 1.0; let mut scale = 1.0;
let mut it = args.iter(); let mut it = args.iter();
while let Some(a) = it.next() { while let Some(a) = it.next() {
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}")); let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
match a.as_str() { match a.as_str() {
"--in" | "-i" => input = Some(val("a file")?), "--in" | "-i" => input = Some(val("a file")?),
"--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))), "--cmd" | "-c" => {
steps.push(Step::Cmd(val("a command id")?, json!({})));
params_for = Some(steps.len() - 1);
}
"--params" | "-p" => { "--params" | "-p" => {
let raw = val("JSON")?; let raw = val("JSON")?;
let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?; let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?;
if !p.is_object() { if !p.is_object() {
return Err(format!("--params must be a JSON object, got {raw}")); return Err(format!("--params must be a JSON object, got {raw}"));
} }
match steps.last_mut() { match params_for.and_then(|i| steps.get_mut(i)) {
Some(Step::Cmd(_, params)) => *params = p, Some(Step::Cmd(_, params)) => *params = p,
_ => return Err("--params must follow a --cmd".into()), _ => return Err("--params must follow a --cmd".into()),
} }
} }
"--export" | "-o" => steps.push(Step::Export(val("a file")?)), "--commands" => {
let source = val("a file, or - for standard input")?;
if source == "-" && std::mem::replace(&mut stdin_read, true) {
return Err("--commands - is given twice: standard input is read once".into());
}
let text = read_commands(&source)?;
let read = parse_commands(&source, &text, steps.len())?;
steps.extend(read);
params_for = None;
}
"--export" | "-o" => {
steps.push(Step::Export(val("a file")?));
params_for = None;
}
"--scale" | "-s" => { "--scale" | "-s" => {
let raw = val("a number")?; let raw = val("a number")?;
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?; scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
@@ -394,10 +496,12 @@ fn run(args: &[String]) -> Result<(), String> {
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`). // Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
let mut results = vec![]; let mut results = vec![];
for step in steps { for step in steps {
// The step's index, as `"$N"` names its result.
let n = results.len();
let r = match step { let r = match step {
Step::Cmd(id, params) => { Step::Cmd(id, params) => {
let params = vectorcraft_engine::steps::resolve(&params, &results).map_err(|e| format!("{id}: {e}"))?; let params = vectorcraft_engine::steps::resolve(&params, &results).map_err(|e| format!("step {n} ({id}): {e}"))?;
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?; let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("step {n} ({id}): {e}"))?;
emit(json!({"step": "cmd", "command": id, "result": r}))?; emit(json!({"step": "cmd", "command": id, "result": r}))?;
r r
} }
@@ -407,7 +511,7 @@ fn run(args: &[String]) -> Result<(), String> {
} else { } else {
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale)) h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
}; };
let r = r.map_err(|e| format!("export {path}: {e}"))?; let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?; emit(json!({"step": "export", "result": r}))?;
r r
} }
+26 -2
View File
@@ -23,13 +23,16 @@ impl Rng {
} }
} }
/// The synthetic document's page, in points.
const SYNTHETIC_PAGE: (f64, f64) = (1600.0, 1200.0);
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page. /// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
pub fn synthetic(n: usize) -> Result<Document, String> { pub fn synthetic(n: usize) -> Result<Document, String> {
let mut d = Document::new(1600.0, 1200.0); let mut d = Document::new(SYNTHETIC_PAGE.0, SYNTHETIC_PAGE.1);
let l = d.layers.first().map(|l| l.id); let l = d.layers.first().map(|l| l.id);
let mut r = Rng(42); let mut r = Rng(42);
for i in 0..n { for i in 0..n {
let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0); let (x, y, s) = (r.next() * SYNTHETIC_PAGE.0, r.next() * SYNTHETIC_PAGE.1, 3.0 + r.next() * 22.0);
let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32); let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32);
let id = d.alloc_id(); let id = d.alloc_id();
let mut node = match i % 3 { let mut node = match i % 3 {
@@ -296,6 +299,27 @@ fn deep(n: usize) -> Result<(), String> {
rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms)); rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms));
note("after rendering the photos with 3 renderers"); note("after rendering the photos with 3 renderers");
} }
// An SVG with one embedded picture opened from its text, as picture-heavy SVGs come: the
// importer's passes over the text before usvg reads it, most of which is the data URL. The
// picture is the synthetic page drawn at 1.25x (about 2 MB as a PNG; the row names its size);
// the hidden rectangle is a marker, so the prepass walks the tree as it does for a file that
// has one, instead of returning at its first check.
{
let k = 1.25;
let (w, h) = ((SYNTHETIC_PAGE.0 * k).round() as u32, (SYNTHETIC_PAGE.1 * k).round() as u32);
let photo = Renderer::new().render(&synthetic(3000)?, w, h, Affine::scale(k), &opts).to_png()?;
let data = vectorcraft_svg::base64_encode(&photo);
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}"><image width="{w}" height="{h}" href="data:image/png;base64,{data}"/><rect width="100" height="100" style="display:none"/></svg>"#
);
// One import checked first, so a failure is reported rather than timed; the same text
// imports the same way each time. Its document is kept for the memory reading.
let imported = vectorcraft_svg::import_with_report(&svg).map_err(|e| e.to_string())?;
let ms = median_ms(5, || drop(vectorcraft_svg::import_with_report(&svg)));
rows.push((format!("import SVG, one embedded {} KB PNG", photo.len() / 1024), ms));
note("after the import of an embedded picture");
drop(imported);
}
// Output hashes, to compare builds: the SVG export and a save → open → save round trip. // Output hashes, to compare builds: the SVG export and a save → open → save round trip.
let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3)); let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3));
let doc = synthetic(n)?; let doc = synthetic(n)?;
+168 -2
View File
@@ -84,11 +84,125 @@ fn scale_sizes_raster_exports_only() {
fn run_reports_errors() { fn run_reports_errors() {
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap(); let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
assert!(!out.status.success()); assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("nope.nothing")); let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}");
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap(); let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
assert!(!out.status.success()); assert!(!out.status.success());
} }
/// The JSON lines `run` printed.
fn json_lines(stdout: &[u8]) -> Vec<Value> {
String::from_utf8_lossy(stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect()
}
/// #3147: `run --commands FILE` reads its steps from a JSON array (`command` or `id`), runs them
/// where the option stands among `--cmd` and `--export`, and they count with those for `"$N"`; a
/// placed file's `dataBase64` longer than a Windows command line holds fits.
#[test]
fn run_reads_steps_from_a_commands_file() {
let steps = tmp("steps.json");
let svg = tmp("steps.svg");
// Base64 of an SVG well past 32,767 characters, the most a Windows command line holds.
let padding = "x".repeat(40_000);
let placed = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><!-- {padding} --><rect width="40" height="20" fill="#118833"/></svg>"##
);
let data = vectorcraft_format::base64_encode(placed.as_bytes());
assert!(data.len() > 32_767);
let file = json!([
{"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 30}},
{"command": "file.place", "params": {"name": "code.svg", "dataBase64": data}},
{"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}},
]);
std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap();
let out = Command::new(BIN)
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()])
.args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines = json_lines(&out.stdout);
let ran: Vec<&str> = lines.iter().map(|l| l["command"].as_str().unwrap_or("export")).collect();
assert_eq!(ran, ["file.new", "shape.rectangle", "file.place", "object.setProps", "document.node", "export"]);
// `$2` in the file and in the `--cmd` after it is the placed art.
assert_eq!(lines[4]["result"]["name"], "barcode", "{}", lines[4]);
let written = std::fs::read_to_string(&svg).unwrap();
assert!(written.contains("118833") && written.contains("barcode"), "{written}");
}
/// `run --commands -` reads JSON Lines from standard input: a byte order mark, blank lines and
/// CRLF line ends are fine, and the first step may be `file.new`.
#[test]
fn run_reads_json_lines_from_standard_input() {
let svg = tmp("stdin.svg");
let mut child = Command::new(BIN)
.args(["run", "--commands", "-", "--export", svg.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let text = "\u{feff}{\"command\":\"file.new\",\"params\":{\"width\":120,\"height\":80}}\n\n{\"command\":\"shape.ellipse\",\"params\":{\"x\":10,\"y\":10,\"width\":40,\"height\":30}}\r\n{\"id\":\"paint.setFill\",\"params\":{\"color\":\"#3366ff\"}}\n";
child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines = json_lines(&out.stdout);
assert_eq!(lines.len(), 4);
assert_eq!(lines[0]["command"], "file.new");
assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff"));
}
/// Malformed `--commands` input stops the run before any step runs, naming the step (counted from
/// 0 with the `--cmd` before it) and the entry or line; input past the size and step limits is
/// refused; a step that fails names its number.
#[test]
fn run_refuses_malformed_commands_naming_the_step() {
let fails = |args: &[&str]| {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
assert!(!out.status.success(), "{args:?}");
(String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len())
};
let bad = |name: &str, text: &[u8], wanted: &[&str]| {
let path = tmp(name);
std::fs::write(&path, text).unwrap();
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", path.to_str().unwrap()]);
assert!(wanted.iter().all(|w| e.contains(w)), "{name}: {e}");
assert_eq!(printed, 0, "{name}: nothing runs");
};
bad("number.json", b"[1]", &["step 1 (entry 1)", "must be an object"]);
bad("both.json", br#"[{"command":"select.all"},{"command":"select.all","id":"select.none"}]"#, &["step 2 (entry 2)", "not both"]);
bad("no-command.json", br#"[{"params":{}}]"#, &["step 1 (entry 1)", "no `command`"]);
bad("id-number.json", br#"[{"command":3}]"#, &["`command` must be a string"]);
bad("params-array.json", br#"[{"command":"select.all","params":[1]}]"#, &["`params` of select.all must be a JSON object"]);
bad("typo.json", br#"[{"command":"select.all","param":{}}]"#, &["unknown key `param`"]);
bad("broken.json", b"[{\"command\":", &["--commands", "broken.json"]);
bad("lines.jsonl", b"{\"command\":\"select.all\"}\n\n{oops}\n", &["step 2 (line 3)"]);
bad("line-array.jsonl", b"{\"command\":\"select.all\"}\n[1]\n", &["step 2 (line 2)"]);
bad("latin1.json", b"[{\"command\":\"select.all\",\"params\":{\"name\":\"\xe9\"}}]", &["not UTF-8"]);
let many = "{\"command\":\"select.all\"}\n".repeat(100_001);
bad("many.jsonl", many.as_bytes(), &["more than 100000 steps"]);
// Past 64 MiB (a sparse file: nothing is written).
let huge = tmp("huge.json");
std::fs::File::create(&huge).unwrap().set_len(64 * 1024 * 1024 + 1).unwrap();
let (e, _) = fails(&["run", "--commands", huge.to_str().unwrap()]);
assert!(e.contains("more than 64 MiB"), "{e}");
let (e, _) = fails(&["run", "--commands", tmp("missing.json").to_str().unwrap()]);
assert!(e.contains("missing.json"), "{e}");
// `--params` sets the `--cmd` just before it, never a step of the file.
let ok = tmp("ok.json");
std::fs::write(&ok, br#"[{"command":"select.all"}]"#).unwrap();
let (e, _) = fails(&["run", "--cmd", "select.all", "--commands", ok.to_str().unwrap(), "--params", "{}"]);
assert!(e.contains("--params must follow a --cmd"), "{e}");
let (e, _) = fails(&["run", "--commands", "-", "--commands", "-"]);
assert!(e.contains("standard input is read once"), "{e}");
// A step that fails is named by its number, after the steps before it ran.
let failing = tmp("failing.json");
std::fs::write(&failing, br#"[{"command":"select.all"},{"command":"nope.nothing"}]"#).unwrap();
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", failing.to_str().unwrap()]);
assert!(e.contains("step 2 (nope.nothing)"), "{e}");
assert_eq!(printed, 2);
}
#[test] #[test]
fn mcp_headless_over_stdio() { fn mcp_headless_over_stdio() {
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
@@ -130,6 +244,58 @@ fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
automation_roots(&empty_dir(dir).join("roots")); automation_roots(&empty_dir(dir).join("roots"));
} }
/// #3546: each root flag may be given more than once: a path inside any of the read roots opens,
/// one inside any of the write roots is written, and everything else, a write root's sibling read
/// root included, is refused.
#[test]
fn mcp_automation_roots_take_several_folders() {
let base = tmp("several-roots");
for d in ["in", "in2", "out", "out2", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
for f in ["in/a.svg", "in2/b.svg", "outside/c.svg"] {
std::fs::write(base.join(f), svg).unwrap();
}
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
let mut child = Command::new(BIN)
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))])
.args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
let calls = [
(call(1, "open_file", json!({"path": file("in", "a.svg")})), false),
(call(2, "open_file", json!({"path": file("in2", "b.svg")})), false),
(call(3, "open_file", json!({"path": file("outside", "c.svg")})), true),
(call(4, "export", json!({"path": file("out", "a.png")})), false),
(call(5, "export", json!({"path": file("out2", "b.png")})), false),
(call(6, "export", json!({"path": file("outside", "c.png")})), true),
(call(7, "export", json!({"path": file("in2", "d.png")})), true),
(call(8, "open_file", json!({"path": file("out2", "b.png")})), true),
];
{
let mut stdin = child.stdin.take().unwrap();
for (m, _) in &calls {
writeln!(stdin, "{m}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("files confined: read ") && stderr.contains("; write "), "{stderr}");
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, calls.iter().map(|(_, e)| *e).collect::<Vec<_>>(), "{replies:?}");
assert!(replies[2]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read roots"), "{}", replies[2]);
assert!(base.join("out/a.png").is_file() && base.join("out2/b.png").is_file());
assert!(!base.join("outside/c.png").exists() && !base.join("in2/d.png").exists());
}
fn automation_roots(base: &std::path::Path) { fn automation_roots(base: &std::path::Path) {
for d in ["in", "out", "outside"] { for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap(); std::fs::create_dir_all(base.join(d)).unwrap();
@@ -153,7 +319,7 @@ fn automation_roots(base: &std::path::Path) {
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}"); assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
}; };
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root"); fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice"); fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-write-root"], "needs a folder"); fails(&["mcp", "--automation-write-root"], "needs a folder");
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots"); fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
+1
View File
@@ -27,6 +27,7 @@ eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
egui = { workspace = true } egui = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
chrono = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true } rfd = { workspace = true }
+36 -23
View File
@@ -6,29 +6,29 @@ use std::ffi::OsString;
use std::path::PathBuf; use std::path::PathBuf;
pub struct AutomationArgs { pub struct AutomationArgs {
pub read_root: Option<PathBuf>, pub read_roots: Vec<PathBuf>,
pub write_root: Option<PathBuf>, pub write_roots: Vec<PathBuf>,
read_given: bool, read_given: bool,
write_given: bool, write_given: bool,
} }
impl AutomationArgs { impl AutomationArgs {
/// The roots the environment gives (`VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`), until
/// the command line gives its own.
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self { pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false } Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect(), read_given: false, write_given: false }
} }
/// Returns whether this argument is a root flag. CLI values override environment defaults /// Returns whether this argument is a root flag. Each flag may be given more than once (#3546),
/// once; a second occurrence is an error, including when the two spellings are mixed. /// in either spelling: the first replaces the environment's root of its kind, the others add
/// folders.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> { pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag { let (roots, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given), "--automation-read-root" => (&mut self.read_roots, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given), "--automation-write-root" => (&mut self.write_roots, &mut self.write_given),
_ => return Ok(false), _ => return Ok(false),
}; };
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline { let value = match inline {
Some(value) => value.to_owned(), Some(value) => value.to_owned(),
None => { None => {
@@ -42,8 +42,10 @@ impl AutomationArgs {
if value.is_empty() { if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder")); return Err(format!("{flag} needs a non-empty folder"));
} }
*root = Some(value.into()); if !std::mem::replace(given, true) {
*given = true; roots.clear();
}
roots.push(value.into());
Ok(true) Ok(true)
} }
} }
@@ -97,17 +99,24 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn roots_override_environment_once_and_leave_other_arguments() { fn roots_override_environment_and_add_up() {
let mut roots = AutomationArgs::new(Some("environment".into()), None); let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into()));
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter(); let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap()); assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--in-window-menus", &mut args).unwrap()); assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into())); assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
assert_eq!(roots.write_root, Some("out".into())); assert_eq!(roots.write_roots, [PathBuf::from("out")]);
assert_eq!(args.next().as_deref(), Some("drawing.svg")); assert_eq!(args.next().as_deref(), Some("drawing.svg"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); // #3546: a flag given again adds a folder, in either spelling.
assert_eq!(roots.read_root, Some("a folder".into())); assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
// A kind the command line leaves alone keeps the environment's root.
let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into()));
assert!(roots.consume("--automation-read-root=a", &mut std::iter::empty()).unwrap());
assert_eq!(roots.write_roots, [PathBuf::from("environment out")]);
} }
#[test] #[test]
@@ -117,11 +126,15 @@ mod tests {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter(); let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into())); assert_eq!(roots.read_roots, [PathBuf::from("read")]);
assert_eq!(roots.write_root, Some("write".into())); assert_eq!(roots.write_roots, [PathBuf::from("write")]);
} }
let mut roots = AutomationArgs::new(None, None); let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
// A bad second folder is an error too, and keeps the first.
assert!(roots.consume(&format!("{flag}=first"), &mut std::iter::empty()).unwrap());
assert!(roots.consume(flag, &mut std::iter::empty()).is_err());
assert_eq!([roots.read_roots.len(), roots.write_roots.len()].iter().sum::<usize>(), 1);
} }
} }
@@ -130,8 +143,8 @@ mod tests {
let mut roots = AutomationArgs::new(None, None); let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into())); assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
assert_eq!(roots.write_root, Some("./-folder".into())); assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
} }
fn control(args: &[&str]) -> Result<Option<u16>, String> { fn control(args: &[&str]) -> Result<Option<u16>, String> {
+40 -18
View File
@@ -93,28 +93,36 @@ fn matches(name: &str, target: &str) -> bool {
} }
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String { pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs(); // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
let (days, rem) = (secs / 86_400, secs % 86_400); // past `i64::MAX` seconds saturates rather than falling back to the epoch.
let (y, m, d) = civil_from_days(days); let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
} }
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's /// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock /// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// value can overflow it. /// so month, day and time of day come from chrono and only the year is carried, which keeps every
fn civil_from_days(days: u64) -> (u64, u64, u64) { /// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
let z = days.saturating_add(719_468); fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
let era = z / 146_097; use chrono::Datelike;
let doe = z % 146_097; let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let mp = (5 * doy + 2) / 153; let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let d = doy - (153 * mp + 2) / 5 + 1; let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
let m = if mp < 10 { mp + 3 } else { mp - 9 }; match (date, time) {
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); (Some(d), Some(t)) => {
(y, m, d) let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
} }
fn numbered(dir: &Path, n: usize) -> PathBuf { fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -387,6 +395,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking. // Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
} }
#[test] #[test]
+40 -9
View File
@@ -1,6 +1,6 @@
//! VectorCraft desktop app. //! VectorCraft desktop app.
//! //!
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]] //! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) //! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
//! //!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on //! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
@@ -70,6 +70,12 @@ struct App {
frames: u64, frames: u64,
/// Keeps the custom title bar's window borderless (Windows, #1758). /// Keeps the custom title bar's window borderless (Windows, #1758).
window_frame: Option<window_frame::WindowFrameFn>, window_frame: Option<window_frame::WindowFrameFn>,
/// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635).
winit_window: Option<std::sync::Arc<winit::window::Window>>,
/// A saved or fitted geometry asked for maximize once the window is mapped (Linux).
pending_maximize: bool,
/// Frames left to retry [`window::apply_pending_maximize`].
maximize_tries: u8,
} }
impl eframe::App for App { impl eframe::App for App {
@@ -147,7 +153,15 @@ impl App {
open_files(&mut self.app, open_documents::take()); open_files(&mut self.app, open_documents::take());
} }
self.app.logic(ctx); self.app.logic(ctx);
window::track(ctx, &mut self.app.ui.window); if self.pending_maximize {
if let Some(w) = &self.winit_window {
self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries);
} else {
self.pending_maximize = false;
}
}
let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized());
window::track(ctx, &mut self.app.ui.window, host_maximized);
if self.app.ui.status == "quit" { if self.app.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close); ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} }
@@ -725,7 +739,9 @@ Options:
--control <port> Start the localhost JSON-lines control server on <port> --control <port> Start the localhost JSON-lines control server on <port>
(or VECTORCRAFT_CONTROL_PORT) (or VECTORCRAFT_CONTROL_PORT)
--automation-read-root <dir> Confine the files control requests read to <dir> --automation-read-root <dir> Confine the files control requests read to <dir>
(may be given more than once)
--automation-write-root <dir> Confine the files control requests write to <dir> --automation-write-root <dir> Confine the files control requests write to <dir>
(may be given more than once)
--in-window-menus Keep the menus inside the window on macOS --in-window-menus Keep the menus inside the window on macOS
(or VECTORCRAFT_IN_WINDOW_MENUS=1) (or VECTORCRAFT_IN_WINDOW_MENUS=1)
--version Print the version and exit --version Print the version and exit
@@ -774,7 +790,7 @@ fn main() -> std::process::ExitCode {
_ => files.push(a), _ => files.push(a),
} }
} }
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args; let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args;
// After the arguments: `--control` and `--version` win over a bad environment value. // After the arguments: `--control` and `--version` win over a bad environment value.
let control_port = match control.port { let control_port = match control.port {
Some(port) => Some(port), Some(port) => Some(port),
@@ -813,12 +829,14 @@ fn main() -> std::process::ExitCode {
// The roots confine the control channel: a bad one keeps the app from starting rather than // The roots confine the control channel: a bad one keeps the app from starting rather than
// leaving agents unconfined. Without a control port there is nothing to confine. // leaving agents unconfined. Without a control port there is nothing to confine.
let roots = if control_port.is_none() { let roots = if control_port.is_none() {
if read_root.is_some() || write_root.is_some() { if !read_roots.is_empty() || !write_roots.is_empty() {
log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control"); log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control");
} }
None None
} else { } else {
match AutomationRoots::new(read_root.as_deref(), write_root.as_deref()) { let read: Vec<&std::path::Path> = read_roots.iter().map(std::path::PathBuf::as_path).collect();
let write: Vec<&std::path::Path> = write_roots.iter().map(std::path::PathBuf::as_path).collect();
match AutomationRoots::new(&read, &write) {
Ok(roots) => roots, Ok(roots) => roots,
Err(e) => { Err(e) => {
log::error!("{e}"); log::error!("{e}");
@@ -949,11 +967,15 @@ fn main() -> std::process::ExitCode {
// which takes over from the copy the preferences file used to hold. // which takes over from the copy the preferences file used to hold.
prefs_dir::attach_pdf_preset_store(&mut app.session); prefs_dir::attach_pdf_preset_store(&mut app.session);
// Fit the window to its monitor, or put it back where it was (still hidden). // Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() { let winit_window = cc.winit_window().cloned();
app.ui.window = Some(window::restore(w, saved_window)); if let Some(w) = &winit_window {
let g = window::restore(w, saved_window);
app.ui.window = Some(g);
#[cfg(windows)] #[cfg(windows)]
set_taskbar_icon(w); set_taskbar_icon(w);
} }
// Linux may not map the window before the first frames, so a saved maximize is retried then (#3635).
let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized);
// User Defined swatch and graphic style libraries live next to the preferences. // User Defined swatch and graphic style libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches); app.session.swatch_libraries.set_user_dir(swatches);
@@ -1015,7 +1037,7 @@ fn main() -> std::process::ExitCode {
// Everywhere the app doesn't say whose work it is (worker threads), the // Everywhere the app doesn't say whose work it is (worker threads), the
// roots hold: unconfined is only what the person at the keyboard does. // roots hold: unconfined is only what the person at the keyboard does.
file_access::confine_process(roots.clone()); file_access::confine_process(roots.clone());
log::info!("control channel confined to the automation roots: read {:?}, write {:?}", roots.read_root(), roots.write_root()); log::info!("control channel confined to the automation roots: {}", roots.describe());
} }
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -1034,7 +1056,16 @@ fn main() -> std::process::ExitCode {
created.ready(); created.ready();
// The content and the pointer stay aligned on Windows (#1758). // The content and the pointer stay aligned on Windows (#1758).
let window_frame = window_frame::service(cc, custom_titlebar); let window_frame = window_frame::service(cc, custom_titlebar);
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0, window_frame })) Ok(Box::new(App {
app,
graphics_loss,
graphics_lost: false,
frames: 0,
window_frame,
winit_window,
pending_maximize,
maximize_tries: 8,
}))
}), }),
) )
}); });
+39 -4
View File
@@ -110,7 +110,8 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
} }
/// Size and place the new window (eframe shows it after drawing the first frame) and return its /// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry. /// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run
/// until the host reports maximized (fixes #3635).
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry { pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect(); let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect(); let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
@@ -121,22 +122,41 @@ pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) ->
} }
// Returns the new size when it applies at once; either way the window gets it. // Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1])); let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
#[cfg(not(target_os = "linux"))]
if g.maximized { if g.maximized {
window.set_maximized(true); window.set_maximized(true);
} }
g g
} }
/// Keep asking the host to maximize until it sticks, or give up after a few frames.
pub fn apply_pending_maximize(window: &winit::window::Window, want: bool, tries: &mut u8) -> bool {
if !want {
return false;
}
if window.is_maximized() {
return false;
}
if *tries == 0 {
return false;
}
*tries -= 1;
window.set_maximized(true);
true
}
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps /// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size. /// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) { /// `host_maximized` is the window manager's idea of maximized when egui does not report it (#3635).
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>, host_maximized: Option<bool>) {
let zoom = ctx.zoom_factor(); let zoom = ctx.zoom_factor();
ctx.input(|i| { ctx.input(|i| {
let v = i.viewport(); let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) { if v.minimized == Some(true) || v.fullscreen == Some(true) {
return; return;
} }
if v.maximized == Some(true) { let maximized = v.maximized == Some(true) || host_maximized == Some(true);
if maximized {
if let Some(g) = geometry { if let Some(g) = geometry {
g.maximized = true; g.maximized = true;
} }
@@ -222,7 +242,7 @@ mod tests {
let mut frame = |info: egui::ViewportInfo| { let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default(); let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info); input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear(); ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, None)).textures_delta.clear();
geometry geometry
}; };
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0)); let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
@@ -253,4 +273,19 @@ mod tests {
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap(); let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
assert_eq!(back.window, None); assert_eq!(back.window, None);
} }
#[test]
fn the_host_maximized_flag_is_remembered_when_egui_does_not_report_it() {
let ctx = egui::Context::default();
let mut geometry = Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: false });
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1920.0, 1080.0));
let mut input = egui::RawInput::default();
input.viewports.insert(
egui::ViewportId::ROOT,
egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: None, ..Default::default() },
);
let mut out = ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, Some(true)));
out.textures_delta.clear();
assert_eq!(geometry, Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: true }));
}
} }
+9 -3
View File
@@ -60,11 +60,17 @@ fn missing_empty_or_option_operands_fail_before_startup() {
} }
} }
/// #3546: a root flag may be given more than once, in either spelling; a bad later operand still
/// stops the app before it starts.
#[test] #[test]
fn duplicate_roots_fail_for_both_spellings() { fn repeated_roots_are_accepted_for_both_spellings() {
for flag in ["--automation-read-root", "--automation-write-root"] { for flag in ["--automation-read-root", "--automation-write-root"] {
rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice")); let out = Command::new(BIN).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap();
rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice")); assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
assert!(out.stderr.is_empty());
rejects(&[flag, ".", flag, ""], &format!("{flag} needs a non-empty folder"));
rejects(&[&format!("{flag}=."), flag], &format!("{flag} needs a folder"));
} }
} }
+50 -18
View File
@@ -11,6 +11,7 @@ Affinity files.
| Affinity | VectorCraft | | Affinity | VectorCraft |
|---|---| |---|---|
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side | | Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
| Master pages (`MpCh`) shown on pages through master page instances (`MPIN`: master, page offset, master page, count) | a locked "Master A, B…" layer at the bottom holding each master page's objects on the pages that show it |
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke | | Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups | | Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) | | Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
@@ -21,22 +22,24 @@ Affinity files.
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask | | Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) | | Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) | | Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance | | Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
| Fill layers (`FRst`) | filled rectangles | | Fill layers (`FRst`) | filled rectangles |
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type | | Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images | | Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images | | Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks |
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them | | Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards; their master pages are left out); other embedded documents and symbols as the picture Affinity cached of them |
| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels (a group's over its children's) |
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same | | Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
Everything else is reported in the import warnings, one line per kind with a count, never Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes, dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial), transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round or straight, stars with rounded points, several fills or strokes on one object (the types other than round or straight, stars with rounded points, several fills or strokes on one object (the
active one is used), outlined or scaled text, text fields such as page numbers, frames with active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the columns or a curved outline, table cell fills, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels with transparency in a document
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal). without a CMYK profile (converted through VectorCraft's working CMYK profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
When the native document can't be read at all, the engine opens the embedded PNG preview instead, When the native document can't be read at all, the engine opens the embedded PNG preview instead,
with a warning that names the reason; that preview can't be placed, used as a template or mined with a warning that names the reason; that preview can't be placed, used as a template or mined
@@ -78,10 +81,22 @@ of CC0 sample files.
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check * **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
written files in Affinity itself. written files in Affinity itself.
The reader also has an independent copy in PhotoCraft's `photocraft-affinity`. The reader also has independent copies in PhotoCraft's `photocraft-affinity` and DesignCraft's
`designcraft-affinity`. Tables, paragraph formats, text scale, master pages, layer effects,
overlays, embedded documents, large pixel layers and HEIC photos were added in DesignCraft
([storytold/designcraft#291](https://github.com/storytold/designcraft/pull/291)) and brought back
here, from the same public fixtures and the thumbnails of documents their owners made, used only
locally. Automatic leading (the font size plus the font's line gap, for artistic and frame text)
was fitted on the thumbnails of one such local document with 56 artboards and on the public Patchy
text-frame fixture.
## Validation ## Validation
* `engine/tests/affinity_features.rs` covers pages, several artboards per canvas and per spread,
master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded
documents (whose artboards don't become artboards) on synthetic documents, and mutates them.
`engine/tests/affinity_local.rs` compares every artboard of local documents with their thumbnail
when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed).
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries, * Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations. mutations.
@@ -104,9 +119,14 @@ The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate. pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins, * Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
multiple spreads and resolution conversion, alongside text recovery and native save/export. multiple spreads and resolution conversion, alongside text recovery and native save/export.
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks * `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive, and
that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole documents with layer effects, colour overlays (also over tables), line styles, paragraph and
reader (`container`) and the object stream (`stream`): character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding
and the embedded stream), and checks that whatever opens also renders and exports.
`tests/import_limits.rs` builds hostile documents against the limits below: grids of 16 million
cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds
of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object
stream (`stream`):
```sh ```sh
cd crates/affinity cd crates/affinity
@@ -132,11 +152,23 @@ anything is allocated: a value takes about 40 bytes in memory however few it too
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000 nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
and cumulative geometry work at four million, rejecting cycles and partial results on limits. and cumulative geometry work at four million, rejecting cycles and partial results on limits.
Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64
(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of
million characters per node before allocation, with a cumulative 16 MiB budget for owned font their resolution) and 512 megapixels for the whole document; pixel masks are read only over what
names, features and gradient stops. Exhaustion warns and uses default attributes for remaining they mask (all of it under artistic text, whose glyphs only the layout places). One budget covers
text, preserving its characters. the whole read: layers, pixels, 16 million characters of stories and 262 144 table cells, so
content that many layers link, or a document embedded many times, can't multiply them. Embedded
documents nest four deep at most; both of their readings are charged to that budget and to the
importing document's archive limits, also when they fail part of the way. Tables have at most
4096 rows and columns and 65 536 cells, and 64 layer effects per node are read. Gradients are
limited to 1024 stops. Text collection is bounded to a million characters per node before
allocation, with a cumulative 16 MiB budget for owned font names (256 bytes at most each),
features and gradient stops; paragraph formats are matched to runs by binary search. Exhaustion
warns and uses default attributes for remaining text, preserving its characters. Colour and
gradient overlays share one work budget per import (`overlay::MAX_WORK`: the nodes and pixels
each is baked into); past it they are left out, and said. In VectorCraft, master pages are
copied onto at most as many objects, characters and pixels as a document may hold, a few times
over, and objects beyond `MAX_COORD` of the origin are left out.
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests. crate has no panics outside tests.
+15
View File
@@ -263,6 +263,21 @@ impl<'a> Archive<'a> {
} }
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC. /// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
/// What is left of the limits for documents embedded in this one.
pub(crate) fn remaining(&self) -> Limits {
Limits { max_entry: self.limits.max_entry, max_total: self.limits.max_total.saturating_sub(self.extracted) }
}
/// Count `n` bytes extracted elsewhere (from embedded documents) against this import.
pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> {
let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?;
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
self.extracted = total;
Ok(())
}
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> { pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone(); let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?; let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
+1
View File
@@ -14,6 +14,7 @@ mod access;
pub mod container; pub mod container;
mod geometry; mod geometry;
pub mod model; pub mod model;
pub mod overlay;
pub mod paint; pub mod paint;
pub mod preview; pub mod preview;
mod raster; mod raster;
+625 -59
View File
@@ -1,4 +1,4 @@
//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes, //! The document model the Craft apps import: spreads, artboards, layers, groups, curves, shapes,
//! text and images, with transforms composed into document space. Anything else is reported //! text and images, with transforms composed into document space. Anything else is reported
//! by [`Document::warnings`] instead of being dropped silently. //! by [`Document::warnings`] instead of being dropped silently.
@@ -175,17 +175,54 @@ pub struct TextRun {
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features. /// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
pub features: Vec<String>, pub features: Vec<String>,
pub all_caps: bool, pub all_caps: bool,
/// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its
/// owner made, all other characters there and in the public corpus have 0).
pub underline: bool,
/// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck
/// through in another document its owner made).
pub strikethrough: bool,
/// Font size in document pixels, before the node's transform. /// Font size in document pixels, before the node's transform.
pub size: f64, pub size: f64,
/// Tracking in em. /// Tracking in em.
pub tracking: f64, pub tracking: f64,
/// Horizontal scale of the glyphs (1 = as designed).
pub h_scale: f64,
/// Fixed line pitch in document pixels, when the run overrides automatic leading. /// Fixed line pitch in document pixels, when the run overrides automatic leading.
pub leading: Option<f64>, pub leading: Option<f64>,
pub fill: Paint, pub fill: Paint,
/// Outlined characters: the outline's paint and width (node space).
pub stroke: Option<crate::paint::Stroke>,
/// The run's paragraph: alignment, indents and spacing.
pub paragraph: Paragraph,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] /// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the
/// node's text scale).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Paragraph {
pub align: Align,
/// Indent of every line but the first, from the left.
pub left: f64,
pub right: f64,
/// Indent of the first line, from the left.
pub first: f64,
pub space_before: f64,
pub space_after: f64,
/// Fixed distance between baselines, when the paragraph sets one.
pub leading: Option<f64>,
/// Automatic leading as a multiple of the font's own line height (ascent plus descent).
pub auto_leading: f64,
}
impl Default for Paragraph {
fn default() -> Self {
Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Align { pub enum Align {
#[default]
Left, Left,
Center, Center,
Right, Right,
@@ -202,12 +239,69 @@ pub struct Text {
pub frame: Option<Rect>, pub frame: Option<Rect>,
/// Node space to document pixels. /// Node space to document pixels.
pub transform: Affine, pub transform: Affine,
/// Node space to document pixels for the type (its linear part matters): artistic text scales
/// with its node, frame text only with its frame's text scale.
pub text_transform: Affine,
}
/// Vertical alignment of a table cell's text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VAlign {
Top,
Center,
Bottom,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TableCell {
pub runs: Vec<TextRun>,
/// Space between the cell edges and its text, in node space: left, top, right, bottom.
pub inset: [f64; 4],
pub valign: VAlign,
/// Part of the cell to its left (merged): it has no text of its own.
pub merged_left: bool,
}
/// A table: a grid of cells with text, and a line on every cell edge.
#[derive(Debug, Clone, PartialEq)]
pub struct Table {
/// Column boundaries (one more than the columns), increasing, in node space.
pub columns: Vec<f64>,
/// Row boundaries (one more than the rows), increasing, in node space.
pub rows: Vec<f64>,
/// Row by row.
pub cells: Vec<TableCell>,
/// Vertical edges row by row, one more per row than the columns; `None` draws no line, and
/// the list is empty when the file's didn't match the grid.
pub vertical: Vec<Option<Stroke>>,
/// Horizontal edges boundary by boundary (one more than the rows), one per column (empty
/// as `vertical` can be).
pub horizontal: Vec<Option<Stroke>>,
/// Node space to document pixels.
pub transform: Affine,
/// Node space to document pixels for the cells' type: resizing a table moves its grid, not
/// its type.
pub text_transform: Affine,
}
impl Table {
pub fn ncols(&self) -> usize {
self.columns.len().saturating_sub(1)
}
pub fn nrows(&self) -> usize {
self.rows.len().saturating_sub(1)
}
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Pixels { pub enum Pixels {
/// Straight-alpha RGBA, 8 bits per channel, row by row. /// Straight-alpha RGBA, 8 bits per channel, row by row.
Rgba8(Vec<u8>), Rgba8(Vec<u8>),
/// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and
/// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a
/// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals
/// into a blue cast.
Cmyka8(Vec<u8>),
/// The original file a placed image was made from (PNG, JPEG…), as embedded. /// The original file a placed image was made from (PNG, JPEG…), as embedded.
Encoded(Vec<u8>), Encoded(Vec<u8>),
} }
@@ -246,11 +340,78 @@ pub enum Kind {
even_odd: bool, even_odd: bool,
}, },
Text(Text), Text(Text),
Table(Table),
Image(Image), Image(Image),
/// A master page shown on this spread's pages: pages `page..page + count` show the master
/// spread [`Document::masters`]`[master]`'s pages from `master_page` on.
MasterInstance {
master: usize,
page: usize,
master_page: usize,
count: usize,
},
/// Something this reader does not import; its children are still imported. /// Something this reader does not import; its children are still imported.
Unsupported, Unsupported,
} }
/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in
/// (radians, clockwise from +x as y points down: π/2 puts it below).
#[derive(Debug, Clone, PartialEq)]
pub enum Effect {
DropShadow {
color: crate::paint::Color,
opacity: f64,
radius: f64,
distance: f64,
angle: f64,
},
InnerShadow {
color: crate::paint::Color,
opacity: f64,
radius: f64,
distance: f64,
angle: f64,
},
OuterGlow {
color: crate::paint::Color,
opacity: f64,
radius: f64,
},
/// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred.
Outline {
color: crate::paint::Color,
opacity: f64,
width: f64,
align: u16,
},
/// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth
/// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default);
/// `inner` is false for the outer, emboss and pillow kinds.
Bevel {
size: f64,
depth: f64,
angle: f64,
highlight: crate::paint::Color,
highlight_opacity: f64,
shadow: crate::paint::Color,
shadow_opacity: f64,
inner: bool,
},
/// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at
/// `opacity` (the node's own alpha is kept).
ColorOverlay {
color: crate::paint::Color,
opacity: f64,
blend: Blend,
},
/// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels).
GradientOverlay {
gradient: crate::paint::Gradient,
opacity: f64,
blend: Blend,
},
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct Node { pub struct Node {
pub class: Tag, pub class: Tag,
@@ -264,6 +425,8 @@ pub struct Node {
pub mask: Option<Path>, pub mask: Option<Path>,
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden. /// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
pub pixel_mask: Option<Image>, pub pixel_mask: Option<Image>,
/// Layer effects the importer can show or bake in (others are reported).
pub effects: Vec<Effect>,
/// Bottom to top. /// Bottom to top.
pub children: Vec<Node>, pub children: Vec<Node>,
} }
@@ -285,12 +448,21 @@ pub struct Document {
/// Pixels per inch: all geometry is in document pixels at this resolution. /// Pixels per inch: all geometry is in document pixels at this resolution.
pub dpi: f64, pub dpi: f64,
pub spreads: Vec<Spread>, pub spreads: Vec<Spread>,
/// Master spreads, which [`Kind::MasterInstance`] nodes show on pages.
pub masters: Vec<Spread>,
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present. /// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
pub saved_by: Option<String>, pub saved_by: Option<String>,
/// Content that was not imported or was approximated, one line per kind, deduplicated. /// Content that was not imported or was approximated, one line per kind, deduplicated.
pub warnings: Vec<String>, pub warnings: Vec<String>,
/// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`,
/// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative
/// colorimetric intent and black-point compensation in every document checked.
pub cmyk_profile: Option<Vec<u8>>,
} }
/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes).
const MAX_PROFILE_BYTES: usize = 32 << 20;
/// Deepest layer nesting followed; deeper content is reported, not imported. /// Deepest layer nesting followed; deeper content is reported, not imported.
const MAX_TREE_DEPTH: usize = 128; const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported. /// Total layer nodes imported.
@@ -300,6 +472,32 @@ const MAX_NODES: usize = 500_000;
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704). /// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
const MAX_LAYER_PIXELS: u64 = 512 << 20; const MAX_LAYER_PIXELS: u64 = 512 << 20;
/// Embedded documents inside embedded documents are followed this deep.
const MAX_EMBED_DEPTH: usize = 4;
/// Characters of all stories together (a story may be shared by many text layers).
const MAX_DOCUMENT_CHARS: usize = 16 << 20;
/// Table cells of all tables together (each becomes a text object with its edge lines).
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
/// documents are read with what their embedding document left, so linking or embedding the
/// same content many times can't multiply the work.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Budget {
/// Layer nodes ([`MAX_NODES`] in all).
pub(crate) nodes: usize,
/// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all).
pub(crate) pixels: u64,
/// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all).
pub(crate) chars: usize,
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
pub(crate) cells: usize,
}
impl Budget {
pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
}
/// Read a document: archive, `doc.dat` and the model. /// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> { pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
read_within(bytes, limits, MAX_LAYER_PIXELS) read_within(bytes, limits, MAX_LAYER_PIXELS)
@@ -307,35 +505,81 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
/// [`read`], its pixel layers holding at most `pixels` pixels together. /// [`read`], its pixel layers holding at most `pixels` pixels together.
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> { pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?; read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document
}
/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted
/// either way.
struct ReadAt {
document: Result<Document, Error>,
extracted: usize,
}
/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
/// deep). What it uses is taken from `budget`, also when it fails part of the way.
fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt {
let mut archive = match Archive::open(bytes, limits) {
Ok(a) => a,
Err(e) => return ReadAt { document: Err(e), extracted: 0 },
};
let document = read_archive(&mut archive, budget, base, depth);
ReadAt { document, extracted: archive.extracted() }
}
fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result<Document, Error> {
let doc = archive.read("doc.dat")?; let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?; let s = stream::parse(&doc)?;
let mut reader = Reader::new(&s, &mut archive); let mut reader = Reader::new(&s, archive, base, depth);
reader.pixels_left = pixels; reader.budget = *budget;
reader.document() let document = reader.document();
*budget = reader.budget;
document
} }
pub(crate) struct Reader<'s, 'a, 'b> { pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) s: &'s Stream, pub(crate) s: &'s Stream,
pub(crate) archive: &'b mut Archive<'a>, pub(crate) archive: &'b mut Archive<'a>,
pub(crate) dpi: f64, pub(crate) dpi: f64,
warnings: BTreeMap<String, usize>, pub(crate) warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>, active: HashSet<ObjId>,
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all). /// Where the spreads go: the identity, or an embedding document's placement.
pub(crate) pixels_left: u64, base: Affine,
/// The linear part of `base`, which also scales frame text.
pub(crate) type_base: Affine,
/// How deep inside embedded documents this one is.
depth: usize,
/// An embedded document's content, waiting to become its node's children.
pending: Option<Vec<Node>>,
/// The master spreads, in the order of [`Document::masters`].
master_ids: Vec<ObjId>,
/// What the rest of the read may use.
pub(crate) budget: Budget,
} }
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self { pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS } let [a, b, c, d, ..] = base.0;
Self {
s,
archive,
dpi: 72.0,
warnings: BTreeMap::new(),
active: HashSet::new(),
base,
type_base: Affine([a, b, c, d, 0.0, 0.0]),
depth,
pending: None,
master_ids: Vec::new(),
budget: Budget::FULL,
}
} }
pub(crate) fn warn(&mut self, what: impl Into<String>) { pub(crate) fn warn(&mut self, what: impl Into<String>) {
*self.warnings.entry(what.into()).or_default() += 1; *self.warnings.entry(what.into()).or_default() += 1;
} }
fn document(mut self) -> Result<Document, Error> { /// The document.
fn document(&mut self) -> Result<Document, Error> {
let s = self.s; let s = self.s;
let root = s.root; let root = s.root;
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0); self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
@@ -347,40 +591,65 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
} }
}); });
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?; let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect();
let mut spreads = Vec::new(); let mut spreads = Vec::new();
for spread in s.objs(doc, b"Chld") { for spread in s.objs(doc, b"Chld") {
if !s.is(spread, b"Sprd") { if let Some(sp) = self.spread(doc, spread)? {
self.warn("an unknown kind of page"); spreads.push(sp);
continue;
} }
let pages = pages(s, spread);
let default_size =
s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
y0: 0.0,
x1: 0.0,
y1: 0.0,
});
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
if mask.is_some() {
self.warn("mask layers directly on a page (imported without them)");
}
spreads.push(Spread { bounds, pages, transparent, nodes });
} }
if spreads.is_empty() { if spreads.is_empty() {
return Err(Error::Malformed("document has no pages")); return Err(Error::Malformed("document has no pages"));
} }
if !s.objs(doc, b"MpCh").is_empty() { let mut masters = Vec::new();
self.warn("master pages (their content is not placed on the pages that use them)"); for master in self.master_ids.clone() {
match self.spread(doc, master)? {
Some(sp) => masters.push(sp),
// Keep the indices of `MasterInstance::master` valid.
None => masters.push(Spread {
bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 },
pages: Vec::new(),
transparent: true,
nodes: Vec::new(),
}),
}
} }
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); let cmyk_profile = s
Ok(Document { dpi: self.dpi, spreads, saved_by, warnings }) .obj(root, b"CMYP")
.and_then(|p| s.bytes(p, b"Data"))
.filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice()))
.map(<[u8]>::to_vec);
let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile })
} }
/// The children of `parent`, and the pixel mask a mask layer among them puts on the parent. /// A spread (`Sprd`) of the document `doc`, or of its master spreads.
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<Image>), Error> { fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result<Option<Spread>, Error> {
let s = self.s;
if !s.is(spread, b"Sprd") {
self.warn("an unknown kind of page");
return Ok(None);
}
let pages = pages(s, spread);
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
y0: 0.0,
x1: 0.0,
y1: 0.0,
});
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
let (nodes, mask) = self.children(spread, self.base, 0)?;
if mask.is_some() {
self.warn("mask layers directly on a page (imported without them)");
}
Ok(Some(Spread { bounds, pages, transparent, nodes }))
}
/// The children of `parent`, and the mask layer among them that masks the parent (with its
/// placement), read later over just what it masks.
#[allow(clippy::type_complexity)]
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<(ObjId, Affine)>), Error> {
let s = self.s; let s = self.s;
let mut out = Vec::new(); let mut out = Vec::new();
let mut mask = None; let mut mask = None;
@@ -391,10 +660,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
continue; continue;
} }
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
match crate::raster::mask(self, c, local.then(world))? { if mask.is_none() {
Some(m) if mask.is_none() => mask = Some(m), mask = Some((c, local.then(world)));
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), } else {
None => {} self.warn("several pixel masks on one layer (only the first is used)");
} }
continue; continue;
} }
@@ -411,10 +680,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
self.warn("layers nested deeper than 128 levels"); self.warn("layers nested deeper than 128 levels");
return Ok(None); return Ok(None);
} }
self.nodes += 1; let Some(left) = self.budget.nodes.checked_sub(1) else {
if self.nodes > MAX_NODES {
return Err(Error::Limit("too many layers")); return Err(Error::Limit("too many layers"));
} };
self.budget.nodes = left;
// A node that contains itself (a shared object linked back into its own subtree). // A node that contains itself (a shared object linked back into its own subtree).
if !self.active.insert(id) { if !self.active.insert(id) {
self.warn("a layer that contains itself"); self.warn("a layer that contains itself");
@@ -441,15 +710,28 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
} else { } else {
self.kind(id, class, world, parent)? self.kind(id, class, world, parent)?
}; };
// This node's embedded content, before its children's `node` calls can take it.
let embedded = self.pending.take();
// A compound's children are its operands, already merged into its outline. // A compound's children are its operands, already merged into its outline.
let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
let (mask, attached_mask) = self.attached_masks(id, world)?; if let Some(embedded) = embedded {
children.extend(embedded);
}
// Pixel masks are read only over what they mask (they can span a whole spread at print
// resolution, a gigapixel).
let over = mask_extent(&kind, &children);
let child_mask = match child_mask {
Some((c, at)) => crate::raster::mask(self, c, at, over)?,
None => None,
};
let (mask, attached_mask) = self.attached_masks(id, world, over)?;
let pixel_mask = attached_mask.or(child_mask); let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id); self.active.remove(&id);
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) { let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children };
self.warn("layer effects (shadows, glows, outlines, bevels…)"); // Gradient overlays are laid out over the node's bounds, also while it is hidden.
} let bounds = tree_bounds(&node);
Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children })) node.effects = self.effects(id, world, bounds);
Ok(Some(node))
} }
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> { fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
@@ -471,6 +753,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let (fills, strokes) = paint::node_paint(self, id, world); let (fills, strokes) = paint::node_paint(self, id, world);
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
} }
b"TxtT" => match crate::text::table(self, id, world) {
Some(t) => Kind::Table(t),
None => {
self.warn("tables that could not be read");
Kind::Unsupported
}
},
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) { b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
Some(t) => Kind::Text(t), Some(t) => Kind::Text(t),
None => { None => {
@@ -482,15 +771,35 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Some(img) => Kind::Image(img), Some(img) => Kind::Image(img),
None => Kind::Unsupported, None => Kind::Unsupported,
}, },
b"EmbN" => match crate::raster::embedded(self, id, world)? { b"MPIN" => {
Some(img) => { // `SLnk` links the instance with the master spread it shows.
self.warn("embedded documents and symbols (imported as pictures of them)"); let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default();
Kind::Image(img) let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o));
let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096);
match master {
Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) },
None => {
self.warn("master page instances without their master");
Kind::Unsupported
}
} }
None => { }
self.warn("embedded documents and symbols without a cached picture"); b"EmbN" => match self.embedded_document(id, world) {
Kind::Unsupported // The embedded document itself, as editable content.
Some(nodes) => {
self.pending = Some(nodes);
Kind::Group
} }
None => match crate::raster::embedded(self, id, world)? {
Some(img) => {
self.warn("embedded documents and symbols (imported as pictures of them)");
Kind::Image(img)
}
None => {
self.warn("embedded documents and symbols without a cached picture");
Kind::Unsupported
}
},
}, },
b"FRst" => { b"FRst" => {
// A fill layer paints its whole bitmap area; its gradient lives in page space. // A fill layer paints its whole bitmap area; its gradient lives in page space.
@@ -515,9 +824,89 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}) })
} }
/// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the
/// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below.
fn effects(&mut self, id: ObjId, world: Affine, bounds: Option<Rect>) -> Vec<Effect> {
let s = self.s;
let mut out = Vec::new();
for e in s.objs(id, b"FiEf").into_iter().take(64) {
if s.bool(e, b"Enab") == Some(false) {
continue;
}
// "Scale with object": lengths follow the node's transform; otherwise they are absolute.
let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 };
let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k;
let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0);
let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c));
let class = s.class(e).map(|c| c.0.to_be_bytes());
match (class.as_ref(), color) {
(Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
(Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
(Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }),
(Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => {
let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a);
out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align });
}
(Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"),
(Some(b"BevE" | b"EmbE"), _) => {
// No radius, no bevel (as Affinity draws it).
let size = len(b"Radi");
if size > 0.0 {
let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white);
let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black);
let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0);
let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0);
let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0);
let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0);
out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner });
}
}
(Some(b"ColO"), Some(color)) => {
let blend = self.effect_blend(e);
out.push(Effect::ColorOverlay { color, opacity, blend });
}
(Some(b"GrdO"), _) => {
let blend = self.effect_blend(e);
// The gradient's unit space spans the node's bounding box from -1 to 1, centred
// on it (y down): checked against the stored previews of real documents.
let space =
bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]));
match (s.obj(e, b"GrFl"), space) {
(Some(fill), Some(space)) => match paint::descriptor(self, fill, space) {
Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }),
Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }),
_ => {}
},
// Nothing visible to lay it over.
(Some(_), None) => {}
(None, _) => self.warn("gradient overlay effects that could not be read (left out)"),
}
}
(Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"),
(Some(b"Gaus"), _) => self.warn("blur effects (left out)"),
_ => self.warn("layer effects of other kinds (left out)"),
}
}
out
}
/// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
fn effect_blend(&mut self, e: ObjId) -> Blend {
match self.s.enumeration(e, b"BlnM") {
None => Blend::Normal,
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
self.warn("an unknown blend mode (imported as Normal)");
Blend::Normal
}),
}
}
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines; /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> { fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option<Rect>) -> Result<(Option<Path>, Option<Image>), Error> {
let s = self.s; let s = self.s;
let attached = s.objs(id, b"AdCh"); let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None; let mut mask: Option<Path> = None;
@@ -534,7 +923,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
} }
} else if class == Tag::of(b"MRst") { } else if class == Tag::of(b"MRst") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
match crate::raster::mask(self, a, local)? { match crate::raster::mask(self, a, local, over)? {
Some(m) if pixels.is_none() => pixels = Some(m), Some(m) if pixels.is_none() => pixels = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {} None => {}
@@ -546,6 +935,93 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Ok((mask, pixels)) Ok((mask, pixels))
} }
/// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds
/// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes,
/// placed so the centre of its page, first artboard or content is the node's origin.
fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option<Vec<Node>> {
let s = self.s;
let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string();
if entry.is_empty() {
return None;
}
if self.depth >= MAX_EMBED_DEPTH {
self.warn("embedded documents nested more than four deep");
return None;
}
let data = self.archive.read(&entry).ok()?;
let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?;
if !crate::is_affinity(inner) {
return None;
}
// A first reading finds its page and resolution, the second places it.
// Both readings are charged to the budget and the archive's limits, also when they fail:
// each does the work.
let mut budget = self.budget;
let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1);
self.budget = budget;
if self.archive.charge(probe.extracted).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
let probe = match probe.document {
Ok(d) => d,
Err(e) => {
self.warn(format!("embedded documents that could not be read ({e})"));
return None;
}
};
let spread = probe.spreads.first()?;
// `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page.
let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2);
let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None });
let page = if content { None } else { spread.pages.first().copied().or(first_board) };
let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds);
// Its pixels aren't needed any more: never hold both readings' at once.
drop(probe);
// Its document pixels are the node's units, whatever either document's resolution.
let k = 1.0;
let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0);
let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world);
if !base.is_finite() {
self.warn("embedded documents with an invalid placement");
return None;
}
let mut budget = self.budget;
let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1);
self.budget = budget;
if self.archive.charge(placed.extracted).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
let doc = placed.document.ok()?;
for w in doc.warnings {
self.warn(format!("{w} [in embedded documents]"));
}
if doc.spreads.len() > 1 {
self.warn("embedded documents with several pages (the first is used)");
}
let mut nodes = doc.spreads.into_iter().next()?.nodes;
// Its master page instances count its own masters, which aren't kept: in this document
// they would show other masters.
fn strip_masters(nodes: &mut [Node], depth: usize) -> bool {
let mut found = false;
for n in nodes {
if matches!(n.kind, Kind::MasterInstance { .. }) {
n.kind = Kind::Unsupported;
found = true;
}
if depth < MAX_TREE_DEPTH {
found |= strip_masters(&mut n.children, depth + 1);
}
}
found
}
if strip_masters(&mut nodes, 0) {
self.warn("master pages of embedded documents (left out)");
}
Some(nodes)
}
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind { fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
let s = self.s; let s = self.s;
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else { let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
@@ -580,6 +1056,96 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
} }
} }
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
/// and tables), in document pixels.
pub fn nodes_bounds(nodes: &[Node]) -> Option<Rect> {
let mut out = None;
walk_bounds(nodes, true, 0, &mut out);
out
}
/// The bounds of `node` and everything in it, hidden or not.
fn tree_bounds(node: &Node) -> Option<Rect> {
let mut out = None;
walk_bounds(std::slice::from_ref(node), false, 0, &mut out);
out
}
fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option<Rect>) {
if depth > MAX_TREE_DEPTH {
return;
}
for n in nodes.iter().filter(|n| n.visible || !visible_only) {
kind_points(&n.kind, &mut |p| add_point(out, p));
walk_bounds(&n.children, visible_only, depth + 1, out);
}
}
/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they
/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here:
/// artistic text, whose glyphs only the layout places.
fn mask_extent(kind: &Kind, children: &[Node]) -> Option<Rect> {
fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option<Rect>) -> bool {
if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) {
return false;
}
let mut own = kind_bounds(kind);
if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) {
// As far as a stroke can reach out of its path, miter joins included.
let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max);
own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach });
}
if let Some(r) = own {
*out = Some(out.map_or(r, |o| union(o, r)));
}
children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out))
}
let mut out = None;
if visit(kind, children, 0, &mut out) { out } else { None }
}
/// The bounds of what one node's own kind draws (not its children): its outline's points, its
/// image's or frame's corners, or artistic text's anchor.
pub fn kind_bounds(kind: &Kind) -> Option<Rect> {
let mut out = None;
kind_points(kind, &mut |p| add_point(&mut out, p));
out
}
fn add_point(out: &mut Option<Rect>, p: Point) {
if p.x.is_finite() && p.y.is_finite() {
let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
*out = Some(out.map_or(r, |o| union(o, r)));
}
}
fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) {
let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y }));
match kind {
Kind::Shape { path, .. } | Kind::Artboard { path, .. } => {
for sp in &path.subpaths {
add(sp.start);
for seg in &sp.segments {
seg.iter().for_each(|p| add(*p));
}
}
}
Kind::Image(img) => {
corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add)
}
Kind::Text(t) => match t.frame {
Some(f) => corners(f, t.transform).into_iter().for_each(add),
None => add(t.transform.apply(t.anchor)),
},
Kind::Table(t) => {
if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) {
corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add);
}
}
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
}
}
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` /// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads). /// equal pages (Publisher 1 facing spreads).
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> { fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
+663
View File
@@ -0,0 +1,663 @@
//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what
//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's
//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps).
//!
//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid
//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow,
//! Darker/Lighter Color) use their usual definitions.
use std::borrow::Cow;
use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point};
use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke};
/// Deepest subtree an overlay is baked into (the reader bounds trees well below this).
const MAX_DEPTH: usize = 256;
/// Most pixels an overlay is baked into per image.
const MAX_PIXELS: usize = 64 << 20;
/// Work the overlays of one import may take together: each overlay counts the nodes and pixels
/// of the subtree it is baked into (which is copied once more for them all). Nested overlays
/// and shared content would otherwise multiply it; past it, overlays are left out and said.
pub const MAX_WORK: u64 = 1 << 29;
#[derive(Clone, Copy)]
enum Source<'a> {
Solid(Color),
Gradient(&'a Gradient),
}
#[derive(Clone, Copy)]
struct Overlay<'a> {
source: Source<'a>,
opacity: f64,
blend: Blend,
}
/// `node` with its colour and gradient overlays baked in, and those of everything in it
/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`],
/// can't pay for copying it). `warn` hears what could only be approximated or was left out.
pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
if overlays(&node.effects).is_empty() {
return Cow::Borrowed(node);
}
let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else {
warn("overlay effects beyond the work one import may take (left out)");
return Cow::Borrowed(node);
};
*work = left;
let mut out = node.clone();
bake_tree(&mut out, work, 0, warn);
Cow::Owned(out)
}
/// The visible colour and gradient overlays among `effects`.
fn overlays(effects: &[Effect]) -> Vec<Overlay<'_>> {
effects
.iter()
.filter_map(|e| match e {
Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }),
Effect::GradientOverlay { gradient, opacity, blend } => {
Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend })
}
_ => None,
})
.filter(|o| o.opacity > 0.0)
.collect()
}
fn is_overlay(e: &Effect) -> bool {
matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. })
}
/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay
/// lies over what its node shows, its children's overlays included. Each overlay pays for the
/// subtree it is baked into out of `work`; past it, it is left out (and said).
fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) {
if depth > MAX_DEPTH {
return;
}
for c in &mut node.children {
bake_tree(c, work, depth + 1, warn);
}
if !node.effects.iter().any(is_overlay) {
return;
}
let (effects, rest): (Vec<Effect>, Vec<Effect>) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay);
node.effects = rest;
let overlays = overlays(&effects);
if overlays.is_empty() {
return;
}
let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX);
let cost = subtree_cost(node, *work / count, 0).saturating_mul(count);
let Some(left) = work.checked_sub(cost) else {
warn("overlay effects beyond the work one import may take (left out)");
return;
};
*work = left;
for o in &overlays {
if matches!(o.blend, Blend::Erase | Blend::PassThrough) {
warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)");
}
bake_node(node, o, 0, warn);
}
}
/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`.
fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 {
if depth > MAX_DEPTH {
return 0;
}
let mut cost: u64 = match &node.kind {
Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1),
Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1),
Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1),
_ => 1,
};
for c in &node.children {
if cost > limit {
break;
}
cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1));
}
cost
}
fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) {
if depth > MAX_DEPTH {
return;
}
match &mut node.kind {
Kind::Shape { fills, strokes, .. } => {
for f in fills.iter_mut() {
*f = paint(f, o, warn);
}
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
}
Kind::Artboard { background, strokes, .. } => {
for f in background.iter_mut() {
*f = paint(f, o, warn);
}
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
}
Kind::Text(t) => {
for r in &mut t.runs {
r.fill = paint(&r.fill, o, warn);
if let Some(s) = &mut r.stroke {
stroke(s, o, warn);
}
}
}
Kind::Table(t) => {
for c in &mut t.cells {
for r in &mut c.runs {
r.fill = paint(&r.fill, o, warn);
if let Some(s) = &mut r.stroke {
stroke(s, o, warn);
}
}
}
for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() {
stroke(s, o, warn);
}
}
Kind::Image(img) => {
if !pixels(img, o) {
warn("overlay effects on images too large to recolour (left out)");
}
}
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
}
for e in &mut node.effects {
match e {
Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o),
Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {}
Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {}
}
}
for c in &mut node.children {
bake_node(c, o, depth + 1, warn);
}
}
fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) {
s.paint = paint(&s.paint, o, warn);
}
/// A paint with the overlay over it.
fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint {
match (p, o.source) {
(Paint::None, _) => Paint::None,
(Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)),
(Paint::Gradient(g), Source::Solid(s)) => {
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() })
}
// Over one colour, the result is the overlay's gradient with each stop laid over it.
(Paint::Solid(c), Source::Gradient(g)) => {
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() })
}
(Paint::Gradient(base), Source::Gradient(g)) => {
if !(o.blend == Blend::Normal && o.opacity >= 1.0) {
warn("gradient overlays on gradients (approximated)");
}
// Exact when the overlay covers it; otherwise over the base gradient's average colour.
let avg = average(base);
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() })
}
}
}
impl Source<'_> {
/// One colour for the source (a gradient's average) where only one fits.
fn at_solid(&self) -> Color {
match self {
Source::Solid(c) => *c,
Source::Gradient(g) => average(g),
}
}
}
fn average(g: &Gradient) -> Color {
let n = g.stops.len().max(1) as f64;
let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0);
for s in &g.stops {
let [sr, sg, sb] = rgb(s.color);
r += sr;
gr += sg;
b += sb;
a += s.color.alpha();
}
Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n }
}
/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept.
fn over(base: Color, source: Color, o: &Overlay) -> Color {
let k = (o.opacity * source.alpha()).clamp(0.0, 1.0);
let alpha = base.alpha();
// Normal mixes within the colour model when both share it, so CMYK stays CMYK.
if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) {
let mix = |a: f64, b: f64| a + (b - a) * k;
match (base, source) {
(Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => {
return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha };
}
(Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha },
(_, s) if k >= 1.0 => return with_alpha(s, alpha),
_ => {}
}
}
let b = rgb(base);
let s = rgb(source);
let blended = blend(o.blend, b, s);
let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k);
Color::Rgb { r, g, b: bl, a: alpha }
}
fn with_alpha(c: Color, a: f64) -> Color {
match c {
Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a },
Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a },
Color::Gray { v, .. } => Color::Gray { v, a },
Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a },
}
}
/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the
/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour.
fn rgb(c: Color) -> [f64; 3] {
let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
match c {
Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)],
Color::Gray { v, .. } => [f(v); 3],
Color::Cmyk { c, m, y, k, .. } => {
let w = 1.0 - f(k);
[(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w]
}
Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f),
}
}
/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65.
fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
let (l, a, b) = (finite(l), finite(a), finite(b));
let fy = (l + 16.0) / 116.0;
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) };
// D50 white.
let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz));
// XYZ (D50) to linear sRGB, Bradford-adapted.
let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z;
let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z;
let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z;
let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 };
[gamma(r), gamma(g), gamma(bl)]
}
/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many.
fn pixels(img: &mut Image, o: &Overlay) -> bool {
let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false };
let Some(len) = n.checked_mul(4) else { return false };
let mut px = match &img.pixels {
Pixels::Rgba8(p) => match p.get(..len) {
Some(p) => p.to_vec(),
None => return false,
},
Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) {
Some(p) => p,
None => return false,
},
// An overlay is blended in RGB: CMYK pixels take the profile-free formula here.
Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) {
Some(p) if p.len() == n * 5 => p
.as_chunks::<5>()
.0
.iter()
.flat_map(|[c, m, y, k, a]| {
let w = 255 - u16::from(*k);
let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8;
[ch(*c), ch(*m), ch(*y), *a]
})
.collect(),
_ => return false,
},
};
let width = img.width.max(1) as usize;
// A solid source is converted once; a gradient is sampled at each pixel's centre.
let solid = match o.source {
Source::Solid(c) => Some((rgb(c), c.alpha())),
Source::Gradient(_) => None,
};
let to_unit = match o.source {
Source::Gradient(g) => match invert(g.transform) {
Some(m) => Some((img.transform.then(m), g)),
None => return false,
},
Source::Solid(_) => None,
};
for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let (s, sa) = match (solid, &to_unit) {
(Some(s), _) => s,
(None, Some((m, g))) => {
let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
let c = sample(g, q);
(rgb(c), c.alpha())
}
(None, None) => continue,
};
let k = (o.opacity * sa).clamp(0.0, 1.0);
let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0);
let m = blend(o.blend, b, s);
for c in 0..3 {
p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
img.pixels = Pixels::Rgba8(px);
true
}
/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says.
fn decode(bytes: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
if probe.into_dimensions().ok()? != (width, height) {
return None;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(width);
limits.max_image_height = Some(height);
limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8);
reader.limits(limits);
Some(reader.decode().ok()?.into_rgba8().into_raw())
}
/// The gradient's colour at unit-space point `q`.
fn sample(g: &Gradient, q: Point) -> Color {
let t = match g.kind {
GradientKind::Linear => q.x,
GradientKind::Radial => q.x.hypot(q.y),
GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU,
};
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } };
if t <= first.offset {
return first.color;
}
for w in g.stops.windows(2) {
let [a, b] = [&w[0], &w[1]];
if t <= b.offset {
let span = b.offset - a.offset;
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
// Affinity's midpoint bias (see `Stop::half_point`).
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
let (x, y) = (rgb(a.color), rgb(b.color));
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
}
}
last.color
}
fn invert(m: Affine) -> Option<Affine> {
let [a, b, c, d, e, f] = m.0;
let det = a * d - b * c;
if !(det.is_finite() && det.abs() > 1e-12) {
return None;
}
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
}
/// The blend of backdrop `b` and source `s` (sRGB, 0..1).
fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] {
use Blend::*;
let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0));
match mode {
Normal | PassThrough | Erase => s,
Darken => sep(f64::min),
Lighten => sep(f64::max),
Multiply => sep(|b, s| b * s),
Screen => sep(screen),
ColorBurn => sep(burn),
ColorDodge => sep(dodge),
Blend::Overlay => sep(|b, s| hard_light(s, b)),
HardLight => sep(hard_light),
SoftLight => sep(soft_light),
Difference => sep(|b, s| (b - s).abs()),
Exclusion => sep(|b, s| b + s - 2.0 * b * s),
Add => sep(|b, s| b + s),
Subtract => sep(|b, s| b - s),
VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }),
LinearLight => sep(|b, s| b + 2.0 * s - 1.0),
PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }),
HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }),
Average => sep(|b, s| (b + s) / 2.0),
Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()),
Reflect => sep(reflect),
Glow => sep(|b, s| reflect(s, b)),
DarkerColor => {
if lum(s) < lum(b) {
s
} else {
b
}
}
LighterColor => {
if lum(s) > lum(b) {
s
} else {
b
}
}
Hue => set_lum(set_sat(s, sat(b)), lum(b)),
Saturation => set_lum(set_sat(b, sat(s)), lum(b)),
Blend::Color => set_lum(s, lum(b)),
Luminosity => set_lum(b, lum(s)),
}
}
fn screen(b: f64, s: f64) -> f64 {
b + s - b * s
}
fn burn(b: f64, s: f64) -> f64 {
if b >= 1.0 {
1.0
} else if s <= 0.0 {
0.0
} else {
1.0 - ((1.0 - b) / s).min(1.0)
}
}
fn dodge(b: f64, s: f64) -> f64 {
if b <= 0.0 {
0.0
} else if s >= 1.0 {
1.0
} else {
(b / (1.0 - s)).min(1.0)
}
}
fn hard_light(b: f64, s: f64) -> f64 {
if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
}
fn soft_light(b: f64, s: f64) -> f64 {
if s <= 0.5 {
b - (1.0 - 2.0 * s) * b * (1.0 - b)
} else {
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
b + (2.0 * s - 1.0) * (d - b)
}
}
fn reflect(b: f64, s: f64) -> f64 {
if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) }
}
fn lum(c: [f64; 3]) -> f64 {
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
}
fn clip_color(c: [f64; 3]) -> [f64; 3] {
let l = lum(c);
let n = c[0].min(c[1]).min(c[2]);
let x = c[0].max(c[1]).max(c[2]);
c.map(|v| {
let mut v = v;
if n < 0.0 && l - n > 1e-12 {
v = l + (v - l) * l / (l - n);
}
if x > 1.0 && x - l > 1e-12 {
v = l + (v - l) * (1.0 - l) / (x - l);
}
v.clamp(0.0, 1.0)
})
}
fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
let d = l - lum(c);
clip_color(c.map(|v| v + d))
}
fn sat(c: [f64; 3]) -> f64 {
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
}
fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
let max = c[0].max(c[1]).max(c[2]);
let min = c[0].min(c[1]).min(c[2]);
if max - min <= 1e-12 {
return [0.0; 3];
}
c.map(|v| (v - min) * s / (max - min))
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(c: Color, opacity: f64, blend: Blend) -> Node {
Node {
class: crate::stream::Tag::of(b"ShpN"),
name: String::new(),
visible: true,
locked: false,
opacity: 1.0,
blend: Blend::Normal,
kind: Kind::Shape {
path: Default::default(),
fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })],
strokes: vec![],
even_odd: false,
},
mask: None,
pixel_mask: None,
effects: vec![Effect::ColorOverlay { color: c, opacity, blend }],
children: vec![],
}
}
fn fill(n: &Node) -> Color {
match &n.kind {
Kind::Shape { fills, .. } => match fills.first() {
Some(Paint::Solid(c)) => *c,
other => panic!("{other:?}"),
},
other => panic!("{other:?}"),
}
}
#[test]
fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() {
let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
assert_eq!(fill(&n), white);
assert!(n.effects.is_empty(), "the overlay is baked, not kept");
// Half way, mixed in RGB.
let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 });
}
#[test]
fn blend_modes_follow_their_formulas() {
let b = [0.25, 0.5, 1.0];
assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]);
assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]);
assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]);
assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]);
// Color keeps the backdrop's luminosity with the source's hue and saturation.
let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]);
assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}");
}
#[test]
fn overlays_recolour_pixels_inside_their_alpha() {
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY });
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
let Kind::Image(img) = &out.kind else { panic!() };
assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0]));
}
#[test]
fn a_gradient_overlay_over_one_colour_becomes_that_gradient() {
let g = Gradient {
kind: GradientKind::Linear,
stops: vec![
Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 },
Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 },
],
transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]),
};
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal);
n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }];
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
let Kind::Shape { fills, .. } = &out.kind else { panic!() };
assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
}
#[test]
fn a_group_overlay_lies_over_its_childrens_overlays() {
let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
let mut group = solid(red, 0.5, Blend::Normal);
group.kind = Kind::Group;
group.children = vec![solid(blue, 1.0, Blend::Normal)];
// The copy (2 nodes), the child's overlay (1) and the group's (2).
let mut work = 5;
let out = apply(&group, &mut work, &mut |_| {}).into_owned();
assert_eq!(work, 0);
let child = out.children.first().unwrap();
// Blue first, then half of the group's red over it.
assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 });
assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked");
// With no work left for the group's overlay, the child's still applies.
let mut warnings = Vec::new();
let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned();
assert_eq!(fill(out.children.first().unwrap()), blue);
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
}
#[test]
fn overlays_stop_at_the_work_one_import_may_take() {
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY });
// 16 pixels and the node, for the copy and for the overlay: 34.
let mut work = 40;
let mut warnings = Vec::new();
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_)));
assert_eq!((work, warnings.len()), (6, 0));
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_)));
assert_eq!(work, 6);
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
}
}
+15 -1
View File
@@ -126,6 +126,9 @@ pub struct Stroke {
/// Gradient stops per gradient. /// Gradient stops per gradient.
const MAX_STOPS: usize = 1024; const MAX_STOPS: usize = 1024;
/// Dash and gap lengths per dash pattern (a pattern has a few; strokes and text runs copy theirs).
const MAX_DASHES: usize = 64;
fn floats(v: &Value) -> Option<Vec<f64>> { fn floats(v: &Value) -> Option<Vec<f64>> {
match v { match v {
Value::Floats(f) => Some(f.clone()), Value::Floats(f) => Some(f.clone()),
@@ -283,7 +286,7 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint
(fills, stroke.into_iter().collect()) (fills, stroke.into_iter().collect())
} }
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> { pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
let s = r.s; let s = r.s;
let paint = descriptor(r, pen, local)?; let paint = descriptor(r, pen, local)?;
if paint == Paint::None { if paint == Paint::None {
@@ -295,6 +298,13 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true); let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
let width = if scales { weight * local.scale() } else { weight }; let width = if scales { weight * local.scale() } else { weight };
let data = s.bytes(lsty, b"Data").unwrap_or_default(); let data = s.bytes(lsty, b"Data").unwrap_or_default();
// Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a
// stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their
// owner made, where every such stroke is invisible in Affinity's own PDF export, and checked
// against the public corpus's thumbnails.
if data.get(10) == Some(&0) {
return None;
}
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0); let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample; // Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
// (2, 2) ends plain lines square and brush lines round; joins import round, which never // (2, 2) ends plain lines square and brush lines round; joins import round, which never
@@ -312,6 +322,10 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
_ => Align::Center, _ => Align::Center,
}; };
let dash = match s.field(lsty, b"Patn") { let dash = match s.field(lsty, b"Patn") {
Some(Value::Array(a)) if a.len() > MAX_DASHES => {
r.warn("dash patterns of more than 64 lengths (imported as solid lines)");
None
}
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => { Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect(); let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) { while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
+246 -59
View File
@@ -39,6 +39,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
&& (s.is(id, b"ImgN") || !has_tiles) && (s.is(id, b"ImgN") || !has_tiles)
{ {
match original(r, name) { match original(r, name) {
// HEIF/HEIC (phone photos) and AVIF originals can't be decoded here: the same pixels
// stored as tiles are used instead (see `decode` when the tiles point back at the original).
Ok(bytes) if is_heif(&bytes) && has_tiles => {}
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })), Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
Err(Error::Limit(e)) => return Err(Error::Limit(e)), Err(Error::Limit(e)) => return Err(Error::Limit(e)),
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"), Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
@@ -66,8 +69,17 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
decode(r, bitmap, crop, world) decode(r, bitmap, crop, world)
} }
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides). /// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand.
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> { fn is_heif(bytes: &[u8]) -> bool {
bytes.get(4..8) == Some(b"ftyp".as_slice())
&& bytes.get(8..12).is_some_and(|brand| {
[b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice())
})
}
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read
/// over `over` (document pixels) when given; outside its pixels a mask hides everything.
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option<crate::model::Rect>) -> Result<Option<Image>, Error> {
let s = r.s; let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) }; let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
@@ -76,7 +88,41 @@ pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Im
r.warn("pixel masks in an unknown format (imported without them)"); r.warn("pixel masks in an unknown format (imported without them)");
return Ok(None); return Ok(None);
} }
decode(r, bitmap, (0, 0, width, height), world) let crop = match over.map(|o| crop_to(o, world, width, height)) {
Some(Some(c)) => c,
// What it masks lies outside it: all hidden, which one black pixel says as well.
Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })),
None => (0, 0, width, height),
};
decode(r, bitmap, crop, world)
}
/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover
/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap.
fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
let inv = invert(world)?;
let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y }));
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in pts {
if !(p.x.is_finite() && p.y.is_finite()) {
return Some((0, 0, width, height));
}
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
}
let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32;
let (cx0, cy0, cx1, cy1) =
(clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height));
(cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1))
}
fn invert(m: Affine) -> Option<Affine> {
let [a, b, c, d, e, f] = m.0;
let det = a * d - b * c;
if !(det.is_finite() && det.abs() > 1e-12) {
return None;
}
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
} }
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches. /// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
@@ -99,33 +145,41 @@ pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Optio
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document). /// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> { fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
decode_within(r, bitmap, crop, world, MAX_PIXELS)
}
/// [`decode`] with at most `max_pixels` decoded pixels.
fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> Result<Option<Image>, Error> {
let s = r.s; let s = r.s;
let (x0, y0, x1, y1) = crop; let (x0, y0, x1, y1) = crop;
if x1 <= x0 || y1 <= y0 { if x1 <= x0 || y1 <= y0 {
return Ok(None); return Ok(None);
} }
let (w, h) = (x1 - x0, y1 - y0); let (w, h) = (x1 - x0, y1 - y0);
let pixels = u64::from(w) * u64::from(h); // Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their
if pixels > MAX_PIXELS { // resolution: every `step`th pixel of every `step`th row.
r.warn("pixel layers larger than 64 megapixels"); let mut step = 1u32;
return Ok(None); while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels {
step *= 2;
if step > 1 << 12 {
r.warn("pixel layers too large to read (left out)");
return Ok(None);
}
} }
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract. // Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
let Some(left) = r.pixels_left.checked_sub(pixels) else { let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step));
let Some(left) = r.budget.pixels.checked_sub(pixels) else {
r.warn("pixel layers beyond the pixels one document may hold"); r.warn("pixel layers beyond the pixels one document may hold");
return Ok(None); return Ok(None);
}; };
r.pixels_left = left; r.budget.pixels = left;
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f); let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format { let (channels, bps) = match format {
Some(0) => (4, 1), Some(0) => (4, 1),
Some(1) => (4, 2), Some(1) => (4, 2),
Some(6) => (1, 1), Some(6) => (1, 1),
Some(7) => (1, 2), Some(7) => (1, 2),
Some(4) => { Some(4) => (5, 1),
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
(5, 1)
}
_ => { _ => {
r.warn("pixel layers in grey, Lab or 32-bit formats"); r.warn("pixel layers in grey, Lab or 32-bit formats");
return Ok(None); return Ok(None);
@@ -152,11 +206,48 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
} else { } else {
None None
}; };
// Tiles kept only in an original that can't be decoded here are left empty (transparent). Where
// a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come
// out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said.
if needs_source && source.is_none() && channels > 1 {
let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.clone(),
_ => Vec::new(),
};
let alpha = states(channels);
let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5))));
if blank {
r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)");
return Ok(None);
}
}
if step > 1 {
// Its stored tiles must fit what is left of the import's extraction budget.
let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum();
if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total {
r.warn("pixel layers too large to read (left out)");
return Ok(None);
}
r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)");
}
let mut cache = CachedPixels { tiles: HashMap::new(), source }; let mut cache = CachedPixels { tiles: HashMap::new(), source };
for c in 1..=channels { for c in 1..=channels {
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?); planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?);
} }
let (w, h) = (w.div_ceil(step), h.div_ceil(step));
let n = (w as usize) * (h as usize); let n = (w as usize) * (h as usize);
let k = f64::from(step);
let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world);
if channels == 5 {
// Ink amounts and alpha as stored (8 bits), interleaved.
let mut cmyka = vec![0u8; n * 5];
for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() {
for (c, v) in px.iter_mut().enumerate() {
*v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0);
}
}
return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform }));
}
let mut rgba = vec![0u8; n * 4]; let mut rgba = vec![0u8; n * 4];
let sample = |p: &Vec<u8>, i: usize| -> f64 { let sample = |p: &Vec<u8>, i: usize| -> f64 {
if bps == 2 { if bps == 2 {
@@ -167,17 +258,9 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
}; };
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect(); let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
let (r2, g, b, a) = if channels == 1 { let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (v[0], v[1], v[2], v[3]) };
(v[0], v[0], v[0], 1.0)
} else if channels == 5 {
let k = 1.0 - v[3];
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
} else {
(v[0], v[1], v[2], v[3])
};
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8)); px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
} }
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform })) Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
} }
@@ -229,28 +312,57 @@ fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
[prefix[0], prefix[1], prefix[2], b'0' + c as u8] [prefix[0], prefix[1], prefix[2], b'0' + c as u8]
} }
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample. /// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state).
fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec<u8>) {
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
};
(tw, th, states)
}
/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`.
fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec<usize> {
let (tw, th, states) = grid(s, bitmap, c, bps);
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut out = Vec::new();
let mut next = 0usize;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
if *state != 4 {
continue;
}
let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE);
if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) {
out.push(next);
}
next += 1;
}
out
}
/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of
/// every `step`th row; `bps` bytes per sample.
fn plane( fn plane(
r: &mut Reader, r: &mut Reader,
bitmap: ObjId, bitmap: ObjId,
c: usize, c: usize,
bps: usize, bps: usize,
(x0, y0, w, h): (u32, u32, u32, u32), (x0, y0, w, h): (u32, u32, u32, u32),
step: u32,
cache: &mut CachedPixels, cache: &mut CachedPixels,
) -> Result<Vec<u8>, Error> { ) -> Result<Vec<u8>, Error> {
let CachedPixels { tiles: cache, source } = cache; let CachedPixels { tiles: cache, source } = cache;
let s = r.s; let s = r.s;
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); let (tw, th, states) = grid(s, bitmap, c, bps);
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
};
let tiles = s.objs(bitmap, &tag(b"Idx", c)); let tiles = s.objs(bitmap, &tag(b"Idx", c));
let row = (w as usize) * bps; let at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize };
let mut out = vec![0u8; row * h as usize]; let row = at.w.div_ceil(at.step) * bps;
let mut out = vec![0u8; row * at.h.div_ceil(at.step)];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize); let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut next = 0usize; let mut next = 0usize;
@@ -267,7 +379,7 @@ fn plane(
continue; continue;
} }
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string(); let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
if !cache.contains_key(&name) { let read = |r: &mut Reader| -> Result<Vec<u8>, Error> {
let mut data = r.archive.read(&name)?; let mut data = r.archive.read(&name)?;
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob. // Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") { if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
@@ -276,10 +388,20 @@ fn plane(
if data.len() != TILE_BYTES { if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size")); return Err(Error::Malformed("pixel tile size"));
} }
Ok(data)
};
// A layer read at a lower resolution is too large to keep its tiles around.
if at.step > 1 {
let tile = read(r)?;
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
if !cache.contains_key(&name) {
let data = read(r)?;
cache.insert(name.clone(), data); cache.insert(name.clone(), data);
} }
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?; let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue; continue;
} }
3 => { 3 => {
@@ -289,9 +411,7 @@ fn plane(
5 => { 5 => {
if let Some((source_width, pixels)) = source.as_ref() { if let Some((source_width, pixels)) = source.as_ref() {
let (ox, oy) = (tx * TILE, ty * TILE); let (ox, oy) = (tx * TILE, ty * TILE);
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| { copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0));
pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
});
continue; continue;
} }
r.warn("pixel layers drawn from an embedded image (left empty)"); r.warn("pixel layers drawn from an embedded image (left empty)");
@@ -300,17 +420,38 @@ fn plane(
_ => return Err(Error::Malformed("unknown pixel tile state")), _ => return Err(Error::Malformed("unknown pixel tile state")),
}; };
if let Some(v) = fill { if let Some(v) = fill {
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v); copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v);
} }
} }
Ok(out) Ok(out)
} }
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane. /// Where a plane's pixels come from: the crop (pixels) and the sampling step.
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) { struct Crop {
for y in oy.max(py0)..(oy + TILE).min(py1) { bps: usize,
x0: usize,
y0: usize,
w: usize,
h: usize,
step: usize,
}
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every
/// `step`th pixel of every `step`th row.
fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
let row = c.w.div_ceil(c.step) * c.bps;
let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps);
for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) {
if !(y - c.y0).is_multiple_of(c.step) {
continue;
}
let out_row = (y - c.y0) / c.step * row;
for x in ox.max(bx0)..(ox + TILE).min(bx1) { for x in ox.max(bx0)..(ox + TILE).min(bx1) {
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) { let px = x / c.bps - c.x0;
if !px.is_multiple_of(c.step) {
continue;
}
if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) {
*b = at(x - ox, y - oy); *b = at(x - ox, y - oy);
} }
} }
@@ -323,6 +464,15 @@ mod tests {
use crate::model::Kind; use crate::model::Kind;
use crate::synth::{self, F, Method, tag}; use crate::synth::{self, F, Method, tag};
#[test]
fn heif_originals_are_recognised_by_their_brand() {
assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0"));
assert!(is_heif(b"\0\0\0\x1cftypavif"));
assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo");
assert!(!is_heif(&png()));
assert!(!is_heif(b"ftyp"));
}
fn png() -> Vec<u8> { fn png() -> Vec<u8> {
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255])); let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
let mut bytes = std::io::Cursor::new(Vec::new()); let mut bytes = std::io::Cursor::new(Vec::new());
@@ -333,18 +483,29 @@ mod tests {
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery, /// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
/// not a claim that Affinity itself accepts the synthetic container. /// not a claim that Affinity itself accepts the synthetic container.
fn fixture(source: &[u8], origin: i32) -> Vec<u8> { fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
fixture_in(source, origin, false)
}
/// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored.
fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec<u8> {
let channels = if cmyk { 5 } else { 4 };
let mut bitmap = vec![ let mut bitmap = vec![
(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)),
(tag(b"BmpW"), F::I32(300)), (tag(b"BmpW"), F::I32(300)),
(tag(b"BmpH"), F::I32(2)), (tag(b"BmpH"), F::I32(2)),
(tag(b"LInf"), F::I32(origin)), (tag(b"LInf"), F::I32(origin)),
(tag(b"TInf"), F::I32(0)), (tag(b"TInf"), F::I32(0)),
(tag(b"Bckg"), F::Entry("c/1".into())), (tag(b"Bckg"), F::Entry("c/1".into())),
]; ];
for c in 1..=4 { for c in 1..=channels {
let states = match (cmyk, c) {
(false, _) => vec![5, 4],
(true, 5) => vec![4, 4],
(true, _) => vec![5, 5],
};
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2))); bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1))); bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4]))); bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states)));
bitmap.push(( bitmap.push((
tag(&super::tag(b"Idx", c)), tag(&super::tag(b"Idx", c)),
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]), F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
@@ -369,18 +530,44 @@ mod tests {
blob.extend((source.len() as u32).to_le_bytes()); blob.extend((source.len() as u32).to_le_bytes());
blob.extend(source); blob.extend(source);
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]); let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect(); let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
synth::container( let names: Vec<String> = (1..=channels).map(|c| format!("d/{c}")).collect();
&[ let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)];
("doc.dat", &doc, Method::Zlib), for (name, tile) in names.iter().zip(&tiles) {
("c/1", &block, Method::Zlib), entries.push((name.as_str(), tile.as_slice(), Method::Zlib));
("d/1", &tiles[0], Method::Zlib), }
("d/2", &tiles[1], Method::Zlib), synth::container(&entries, None)
("d/3", &tiles[2], Method::Zlib), }
("d/4", &tiles[3], Method::Zlib),
], #[test]
None, fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() {
) let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap();
assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings);
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind);
}
#[test]
fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() {
let bytes = fixture(&png(), 0);
let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap();
let doc = archive.read("doc.dat").unwrap();
let s = stream::parse(&doc).unwrap();
let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap();
let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0);
// 20 × 2 pixels over a budget of 10: every other pixel of every other row.
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap();
assert_eq!((image.width, image.height), (10, 1));
assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two");
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
for x in 0..10 {
let sx = 2 * x;
let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] };
assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}");
}
assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)"));
// Within budget, nothing changes.
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap();
assert_eq!((image.width, image.height), (20, 2));
} }
#[test] #[test]
+330 -99
View File
@@ -2,7 +2,7 @@
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning, //! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
//! OpenType features) is left to the importing application. //! OpenType features) is left to the importing application.
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect}; use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect};
use crate::paint::{self, Color, Paint}; use crate::paint::{self, Color, Paint};
use crate::stream::{ObjId, Value}; use crate::stream::{ObjId, Value};
@@ -11,92 +11,16 @@ const MAX_CHARS: usize = 1 << 20;
/// Shared character attributes must not amplify into unbounded font/feature/gradient data. /// Shared character attributes must not amplify into unbounded font/feature/gradient data.
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20; const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
/// Ends a table cell in a table's story (Affinity's break glyph of kind 4).
pub const CELL_BREAK: char = '\u{1F}';
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> { pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
let s = r.s; let s = r.s;
let story = s.obj(id, b"StSt")?; let story = s.obj(id, b"StSt")?;
let frame = s.obj(id, b"TxtH")?; let frame = s.obj(id, b"TxtH")?;
let artistic = s.is(frame, b"ArFr"); let artistic = s.is(frame, b"ArFr");
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?; let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
let mut runs = Vec::new(); let (runs, align) = story_runs(r, story, world);
let mut align = None;
let mut total = 0usize;
let mut placeholders = false;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
for block in s.objs(story, b"Blok") {
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
};
// Bound collection itself, including placeholders, before allocating the character buffer.
let budget = MAX_CHARS - total;
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(budget + 1).collect(),
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let segments = s.objs(glyphs, b"Mixd");
if segments.is_empty() {
r.warn("a text block without readable characters (left out)");
continue;
}
let mut v = Vec::new();
for seg in segments {
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
if v.len() > budget {
break;
}
}
v
}
};
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
r.warn("text longer than a million characters (truncated)");
}
total += chars.len();
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
};
let end = index.min(chars.len());
if index > chars.len() || end < start {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
}
if end <= start {
continue;
}
let text = characters(&chars, start, end);
start = end;
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
}
if start < chars.len() {
let text = characters(&chars, start, chars.len());
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
}
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
if let Some(attrs) = s.obj(paragraph, b"Item") {
let paragraph = paragraph_align(r, attrs);
match align {
None => align = Some(paragraph),
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
_ => {}
}
}
}
if truncated {
break;
}
}
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
let align = align.unwrap_or(Align::Left);
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0); let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
let anchor_x = match align { let anchor_x = match align {
Align::Center => (bounds.x0 + bounds.x1) / 2.0, Align::Center => (bounds.x0 + bounds.x1) / 2.0,
@@ -111,19 +35,256 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
r.warn("text frames with a curved outline (imported as rectangular frames)"); r.warn("text frames with a curved outline (imported as rectangular frames)");
} }
} }
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world }) let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) };
Some(Text {
runs,
align,
anchor: Point { x: anchor_x, y: first_baseline },
frame: if artistic { None } else { Some(bounds) },
transform: world,
text_transform,
})
}
/// Rows and columns a table may have.
const MAX_TABLE_LINES: usize = 4096;
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
const MAX_TABLE_CELLS: usize = 1 << 16;
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
let s = r.s;
let story = s.obj(id, b"StSt")?;
let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?;
let bounds = |pos: Option<ObjId>| -> Option<Vec<f64>> {
let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None };
let v: Vec<f64> = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::<Option<_>>()?;
(v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v)
};
let columns = bounds(s.obj(tabl, b"CPos"))?;
let rows = bounds(s.obj(tabl, b"RPos"))?;
let (ncols, nrows) = (columns.len() - 1, rows.len() - 1);
let count = ncols.checked_mul(nrows)?;
if count > MAX_TABLE_CELLS {
r.warn("tables with more than 65536 cells (left out)");
return None;
}
let Some(left) = r.budget.cells.checked_sub(count) else {
r.warn("tables beyond the cells one document may hold (left out)");
return None;
};
r.budget.cells = left;
let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
// Formatting that doesn't match the grid would land on the wrong cells (and merging them,
// move text between cells): only text then.
let cell_objs = if cell_objs.len() == count {
cell_objs
} else {
r.warn("tables whose cells don't match their grid (cell formatting left out)");
Vec::new()
};
let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false };
let mut cells: Vec<TableCell> = (0..count).map(|_| blank()).collect();
for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() {
if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) {
cell.inset = inset;
}
cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) {
Some(1) => VAlign::Center,
Some(2) => VAlign::Bottom,
_ => VAlign::Top,
};
// A cell merged into the one on its left; the first column has nothing to its left.
cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true);
}
// The story holds the text of every cell that isn't merged away, row by row, each ended by a
// cell break.
let owner_list: Vec<usize> = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect();
let mut owners = owner_list.into_iter();
let mut target = owners.next();
let mut extra = false;
let (runs, _) = story_runs(r, story, world);
for run in runs {
let mut parts = run.text.split(CELL_BREAK).peekable();
while let Some(part) = parts.next() {
if !part.is_empty() {
match target.and_then(|i| cells.get_mut(i)) {
Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
None => extra = true,
}
}
if parts.peek().is_some() {
target = owners.next();
}
}
}
if extra {
r.warn("table text beyond its last cell (left out)");
}
let mut edges = |tag: &[u8; 4], expected: usize| -> Vec<Option<crate::paint::Stroke>> {
let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default();
if objs.len() != expected {
r.warn("tables whose lines don't match their grid (lines left out)");
return Vec::new();
}
objs.into_iter()
.map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) {
(Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false),
_ => None,
})
.collect()
};
let vertical = edges(b"CEdg", (ncols + 1) * nrows);
let horizontal = edges(b"REdg", ncols * (nrows + 1));
if let Some(format) = s.obj(tabl, b"TFmt") {
let filled =
s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN")));
if filled {
r.warn("table cell fills (left out)");
}
}
let text_transform = text_scale(r, id).then(r.type_base);
Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform })
}
/// A story's character runs, each with its paragraph's format, and the first paragraph's
/// alignment. Table cells end with [`CELL_BREAK`].
pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<TextRun>, Align) {
let s = r.s;
let mut runs = Vec::new();
let mut first_align = None;
let mut total = 0usize;
let mut placeholders = false;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
for block in s.objs(story, b"Blok") {
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
};
// Bound collection itself, including placeholders, before allocating the character buffer:
// by the story's limit and by what the document's other stories left.
let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total));
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(budget + 1).collect(),
None => {
// Inline objects (page numbers, anchors, cell ends) each take one index before
// the segment text.
let segments = s.objs(glyphs, b"Mixd");
if segments.is_empty() {
r.warn("a text block without readable characters (left out)");
continue;
}
let mut v = Vec::new();
for seg in segments {
for inline in s.objs(seg, b"Glys") {
if v.len() > budget {
break;
}
if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) {
v.push(CELL_BREAK);
} else {
v.push('\u{FFFC}');
placeholders = true;
}
}
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len())));
if v.len() > budget {
break;
}
}
v
}
};
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
if budget < MAX_CHARS - total {
r.warn("text beyond the characters one document may hold (truncated)");
} else {
r.warn("text longer than a million characters (truncated)");
}
}
total += chars.len();
// Paragraph formats by end index; a run without an index covers the rest of the block.
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
let mut open: Option<Paragraph> = None;
for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
let Some(attrs) = s.obj(run, b"Item") else { continue };
let a = paragraph(r, attrs);
first_align.get_or_insert(a.align);
match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) {
Some(end) => paragraphs.push((end, a)),
None => match open {
None => open = Some(a),
Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"),
_ => {}
},
}
}
// Sorted by end (they are in order in every file), so both lookups below are binary
// searches: a block can have as many paragraphs and runs as characters.
paragraphs.sort_by_key(|(end, _)| *end);
let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default();
let mut cuts: Vec<usize> = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect();
cuts.dedup();
let push = |r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, from: usize, to: usize, budget: &mut Option<usize>| {
// Split at paragraph ends so every run has one paragraph format.
let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default();
let mut a = from;
for &c in inside.iter().chain(std::iter::once(&to)) {
push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a));
a = c;
}
};
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
};
let end = index.min(chars.len());
if index > chars.len() || end < start {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
}
if end <= start {
continue;
}
push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget);
start = end;
}
if start < chars.len() {
push(r, &mut runs, None, start, chars.len(), &mut attribute_budget);
}
if truncated {
break;
}
}
r.budget.chars = r.budget.chars.saturating_sub(total);
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
(runs, first_align.unwrap_or(Align::Left))
} }
fn characters(chars: &[char], start: usize, end: usize) -> String { fn characters(chars: &[char], start: usize, end: usize) -> String {
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect() chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
} }
fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text: String, world: Affine, attribute_budget: &mut Option<usize>) { fn push_run(
r: &mut Reader,
runs: &mut Vec<TextRun>,
attrs: Option<ObjId>,
text: String,
world: Affine,
attribute_budget: &mut Option<usize>,
paragraph: Paragraph,
) {
if text.is_empty() { if text.is_empty() {
return; return;
} }
let run = match attrs { let run = match attrs {
Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget), Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) },
_ => { _ => {
// This is a recovery policy, not an assertion about unspecified Affinity attributes. // This is a recovery policy, not an assertion about unspecified Affinity attributes.
if attrs.is_none() { if attrs.is_none() {
@@ -137,10 +298,15 @@ fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text:
italic: false, italic: false,
features: Vec::new(), features: Vec::new(),
all_caps: false, all_caps: false,
underline: false,
strikethrough: false,
size: 12.0 * r.dpi / 72.0, size: 12.0 * r.dpi / 72.0,
tracking: 0.0, tracking: 0.0,
h_scale: 1.0,
leading: None, leading: None,
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }), fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
stroke: None,
paragraph,
} }
} }
}; };
@@ -195,13 +361,33 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }); fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
} }
} }
if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) // Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes).
{ let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) {
r.warn("outlined (stroked) text"); (true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false),
} _ => None,
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) { };
r.warn("horizontally scaled text"); // Its gradient stops and dashes are owned data too, charged like the fill's.
if let Some(st) = &stroke {
let stops = match &st.paint {
Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>()),
_ => 0,
};
let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::<f64>()));
if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) {
stroke = None;
}
} }
// Underline (slot 0) and strikethrough (slot 1): 1 is a single line.
let mut decoration = |i: usize| match slot(ints, i, int) {
Some(1) => true,
Some(0) | None => false,
Some(_) => {
r.warn("text decorations of an unknown kind (left out)");
false
}
};
let (underline, strikethrough) = (decoration(0), decoration(1));
let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0);
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file // A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common. // (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af // Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
@@ -220,8 +406,8 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) { if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
r.warn("font width variants (exact face depends on the installed PostScript font)"); r.warn("font width variants (exact face depends on the installed PostScript font)");
} }
let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default()); let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default()); let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget); let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400); let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
let weight = if (1..=1000).contains(&weight) { let weight = if (1..=1000).contains(&weight) {
@@ -238,10 +424,15 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false), italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
features, features,
all_caps, all_caps,
underline,
strikethrough,
size, size,
tracking, tracking,
h_scale,
leading, leading,
fill, fill,
stroke,
paragraph: Paragraph::default(),
} }
} }
@@ -260,6 +451,17 @@ fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -
} }
} }
/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it.
const MAX_FONT_NAME: usize = 256;
fn font_name(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
if value.len() > MAX_FONT_NAME {
r.warn("font names longer than 256 bytes (the default font is used)");
return String::new();
}
metadata_string(r, budget, value)
}
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String { fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
let bytes = value.len().saturating_add(std::mem::size_of::<String>()); let bytes = value.len().saturating_add(std::mem::size_of::<String>());
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() } if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
@@ -303,13 +505,18 @@ fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Op
(features, all_caps) (features, all_caps)
} }
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align { /// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing /// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading
// fixtures. Their layout is not represented by this importer; retain text and report the gap. /// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) { /// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space
r.warn("paragraph indents or spacing (imported with defaults)"); /// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing
} /// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the
match slot(r.s.field(p, b"Ints"), 0, int) { /// thumbnail of a document whose owner made it earlier.
fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph {
let doubles = r.s.field(p, b"Doub");
let ints = r.s.field(p, b"Ints");
let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0);
let align = match slot(ints, 0, int) {
Some(1) => Align::Center, Some(1) => Align::Center,
Some(2) => Align::Right, Some(2) => Align::Right,
Some(3) => Align::Justify, Some(3) => Align::Justify,
@@ -318,5 +525,29 @@ fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
r.warn("an unknown paragraph alignment (imported as left)"); r.warn("an unknown paragraph alignment (imported as left)");
Align::Left Align::Left
} }
};
let leading = match slot(ints, 1, int) {
Some(2) => Some(at(1)).filter(|l| *l > 0.0),
Some(0) | None => None,
Some(_) => {
r.warn("paragraph leading of an unknown kind (imported as automatic)");
None
}
};
let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0);
Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading }
}
/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set.
/// Neither the frame's own transform nor its groups' scale its type (checked against the
/// thumbnails of tables and frames inside scaled groups).
fn text_scale(r: &mut Reader, id: ObjId) -> Affine {
match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) {
Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]),
Some(_) => {
r.warn("text frames with an invalid text scale (imported unscaled)");
Affine::IDENTITY
}
None => Affine::IDENTITY,
} }
} }
+182
View File
@@ -0,0 +1,182 @@
//! Crafted documents can't make the reader allocate or work without bound: tables, shared
//! stories, paragraphs and embedded documents stay within the whole read's budget, however often
//! a file links or embeds the same content. Original synthetic documents in the reader's
//! accepted schema; no Affinity software or files were used.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod support {
pub mod text_fixture;
}
use support::text_fixture::*;
use vectorcraft_affinity::{
Document, Error, Limits,
model::{Kind, Node},
read,
synth::{self, F, Method, tag},
};
type Field = (vectorcraft_affinity::stream::Tag, F);
/// A document of one spread holding `nodes`, plus these other archive entries.
fn file(nodes: Vec<F>, entries: &[(&str, &[u8])]) -> Vec<u8> {
let spread = F::Obj(tag(b"Sprd"), vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(nodes))]);
let mut root = vec![(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
let mut next_id = 1000;
for (_, value) in &mut root {
shared_objects(value, &mut next_id);
}
let data = synth::stream(&root);
let mut all = vec![("doc.dat", data.as_slice(), Method::Zlib)];
all.extend(entries.iter().map(|(name, bytes)| (*name, *bytes, Method::Stored)));
synth::container(&all, None)
}
fn open(nodes: Vec<F>) -> Document {
read(&file(nodes, &[]), Limits::default()).unwrap()
}
fn all(nodes: &[Node]) -> Vec<&Node> {
let mut out = Vec::new();
for n in nodes {
out.push(n);
out.extend(all(&n.children));
}
out
}
fn warned(d: &Document, needle: &str) -> bool {
d.warnings.iter().any(|w| w.contains(needle))
}
fn story(text: &str) -> F {
F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![block(utf8(text), vec![run(text.chars().count() as i32, None)], &[])]))])
}
fn artistic(story: F) -> F {
F::Obj(
tag(b"TxtA"),
vec![
(tag(b"StSt"), story),
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 20.0])), (tag(b"ArtV"), F::F64(16.0))])),
],
)
}
/// A table frame (`TbFr`) with a grid of `cols` × `rows` cells and no cell objects or lines.
fn grid(cols: usize, rows: usize) -> Vec<Field> {
let pos = |n: usize| {
let v: Vec<f64> = (0..=n).map(|i| i as f64).collect();
F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", &v))])
};
let table = F::Obj(tag(b"Tabl"), vec![(tag(b"CPos"), pos(cols)), (tag(b"RPos"), pos(rows))]);
vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, cols as f64, rows as f64])), (tag(b"Tabl"), table)]
}
fn table(frame: F) -> F {
F::Obj(tag(b"TxtT"), vec![(tag(b"StSt"), story("A")), (tag(b"TxtH"), frame)])
}
fn tables(d: &Document) -> Vec<&vectorcraft_affinity::model::Table> {
all(&d.spreads[0].nodes).into_iter().filter_map(|n| if let Kind::Table(t) = &n.kind { Some(t) } else { None }).collect()
}
#[test]
fn a_table_of_more_than_65536_cells_is_left_out_before_its_cells_are_made() {
// 4096 × 4096 is within each dimension's limit but 16.7 million cells.
let d = open(vec![table(F::Obj(tag(b"TbFr"), grid(4096, 4096))), table(F::Obj(tag(b"TbFr"), grid(256, 256)))]);
let t = tables(&d);
assert_eq!(t.len(), 1);
assert_eq!((t[0].cells.len(), t[0].vertical.len(), t[0].horizontal.len()), (65536, 0, 0), "no lines when they don't match");
assert!(warned(&d, "tables with more than 65536 cells"), "{:?}", d.warnings);
}
#[test]
fn tables_sharing_one_grid_stay_within_the_cells_of_one_document() {
// One 65536-cell grid linked from five tables: four fill the document's 262144 cells.
let shared = F::Def(2_000_000, vec![tag(b"TbFr")], grid(256, 256));
let d = open((0..5).map(|i| table(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
assert_eq!(tables(&d).len(), 4);
assert!(warned(&d, "tables beyond the cells one document may hold"), "{:?}", d.warnings);
}
#[test]
fn a_shared_story_is_read_within_the_characters_of_one_document() {
// A story of a million characters linked from twenty text layers: 16 million in all.
let text = "x".repeat(1 << 20);
let shared = F::Def(2_000_000, vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block(utf8(&text), vec![run(1 << 20, None)], &[])]))]);
let d = open((0..20).map(|i| artistic(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
let chars: usize = all(&d.spreads[0].nodes)
.into_iter()
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t.runs.iter().map(|r| r.text.len()).sum::<usize>()) } else { None })
.sum();
assert_eq!(chars, 16 << 20);
assert!(warned(&d, "text beyond the characters one document may hold"), "{:?}", d.warnings);
}
#[test]
fn many_paragraphs_and_runs_are_matched_in_about_linear_time() {
// 200 000 paragraphs, each with its own run and format.
let n = 200_000;
let text = "a\u{2029}".repeat(n);
let fmt =
|end: usize| vec![(tag(b"Indx"), F::I32(end as i32)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[1]))]))];
let blk = F::Obj(
tag(b"StBl"),
vec![
(tag(b"Glyp"), utf8(&text)),
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), (1..=n).map(|i| run(2 * i as i32, None)).collect()))])),
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), (1..=n).map(|i| fmt(2 * i)).collect()))])),
],
);
let started = std::time::Instant::now();
let d = open(vec![artistic(F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))]))]);
let Kind::Text(t) = &d.spreads[0].nodes[0].kind else { panic!("text") };
assert_eq!(t.runs.len(), n);
// (About half a second; quadratic matching took over half a minute on the same machine.)
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
}
/// A shape node of `size` px.
fn rect(size: f64) -> F {
F::Obj(tag(b"ShpN"), vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size, size])), (tag(b"Shpe"), F::Obj(tag(b"ShNR"), vec![]))])
}
/// An instance of the embedded document in archive entry `edc/1`, with `children` of its own.
fn embedded(children: Vec<F>) -> F {
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
let mut fields = vec![(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content)]))];
if !children.is_empty() {
fields.push((tag(b"Chld"), F::Shared(children)));
}
F::Obj(tag(b"EmbN"), fields)
}
fn embedding(inner: &[u8], nodes: Vec<F>) -> Vec<u8> {
let mut entry = b"EmDc\0\0\0\0".to_vec();
entry.extend_from_slice(inner);
file(nodes, &[("edc/1", &entry)])
}
#[test]
fn an_embedded_documents_content_stays_with_its_node() {
// Read before the node's own children, which must not take it.
let inner = file(vec![rect(10.0), rect(20.0)], &[]);
let d = read(&embedding(&inner, vec![embedded(vec![rect(5.0)])]), Limits::default()).unwrap();
let node = &d.spreads[0].nodes[0];
assert_eq!(node.children.len(), 3, "its own child and the embedded document's two shapes");
assert!(node.children.iter().all(|c| c.children.is_empty()));
}
#[test]
fn embedding_one_document_many_times_shares_one_budget() {
// 2000 shapes, embedded 300 times and read twice each: 1.2 million nodes, past the 500 000
// a whole read may take (each embedding used to have an allowance of its own).
let inner = file((0..2000).map(|_| rect(10.0)).collect(), &[]);
let outer = embedding(&inner, (0..300).map(|_| embedded(Vec::new())).collect());
assert!(matches!(read(&outer, Limits::default()), Err(Error::Limit(_))));
// Embedded fewer times, it opens.
let d = read(&embedding(&inner, (0..50).map(|_| embedded(Vec::new())).collect()), Limits::default()).unwrap();
assert_eq!(all(&d.spreads[0].nodes).len(), 50 + 50 * 2000);
}
+7 -4
View File
@@ -103,13 +103,16 @@ fn rotated_scaled_pixels_keep_the_full_affine_transform() {
} }
#[test] #[test]
fn cmyk_and_lab_losses_are_reported() { fn cmyk_pixels_stay_cmyk_and_lab_losses_are_reported() {
// CMYK layers keep their inks for the importer to convert through a colour profile.
let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return }; let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return };
assert_eq!(images.len(), 1); assert_eq!(images.len(), 1);
assert_eq!((images[0].width, images[0].height), (64, 48)); assert_eq!((images[0].width, images[0].height), (64, 48));
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() }; let Pixels::Cmyka8(pixels) = &images[0].pixels else { panic!() };
assert_eq!(crc32fast::hash(pixels), 0x5872bb76); assert_eq!(pixels.len(), 64 * 48 * 5);
assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]); assert_eq!(crc32fast::hash(pixels), 0x56b1f659);
assert!(cmyk.warnings.is_empty(), "{:?}", cmyk.warnings);
assert_eq!(cmyk.cmyk_profile, None, "this file embeds no profile");
let (_, lab, images) = read_fixture("patchy-lab.af").unwrap(); let (_, lab, images) = read_fixture("patchy-lab.af").unwrap();
assert!(images.is_empty()); assert!(images.is_empty());
assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]); assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]);
@@ -1,33 +1,33 @@
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"] affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"] affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"] affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"] affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[] patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"] patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=8497c917;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"] patchy-cmyk.af dpi=72;spreads=1;model_crc32=56754ce9;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] patchy-dpi300.af dpi=300;spreads=1;model_crc32=2561f9d3;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=ab4b6fbf;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"] patchy-fx-blur.af dpi=72;spreads=1;model_crc32=29b5083b;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["blur effects (left out) (2×)"]
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"] patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=96cd10be;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=[]
patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"] patchy-fx.af dpi=72;spreads=1;model_crc32=7c8d4d20;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=[]
patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[] patchy-group.af dpi=72;spreads=1;model_crc32=dc37ab6c;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"] patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=38c9c5f4;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"] patchy-lab.af dpi=72;spreads=1;model_crc32=df3a4e40;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"] patchy-live-filter.af dpi=72;spreads=1;model_crc32=12235fe9;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] patchy-rgba16.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] patchy-rgba8.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"] patchy-shapes-2.af dpi=72;spreads=1;model_crc32=886e9852;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[] patchy-shapes.af dpi=72;spreads=1;model_crc32=c1790e97;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[] patchy-text-artistic.af dpi=72;spreads=1;model_crc32=1d33192c;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"] patchy-text-caps.af dpi=72;spreads=1;model_crc32=24e52518;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"] patchy-text-frame.af dpi=72;spreads=1;model_crc32=3bff8ef9;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"] patchy-text-indent.af dpi=72;spreads=1;model_crc32=dc36dfc5;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"] patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=6408f39b;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"] patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"] patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"] patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[] patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
@@ -35,7 +35,12 @@ pub fn signature(doc: &Document) -> String {
} }
"texts" "texts"
} }
Kind::Table(t) => {
*counts.entry("table_cells").or_default() += t.cells.len();
"tables"
}
Kind::Image(_) => "images", Kind::Image(_) => "images",
Kind::MasterInstance { .. } => "master_instances",
Kind::Unsupported => "unsupported", Kind::Unsupported => "unsupported",
}; };
*counts.entry(key).or_default() += 1; *counts.entry(key).or_default() += 1;
@@ -140,7 +140,7 @@ pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
// Shared arrays require definitions/references, whereas inline fields use Obj. Give the original // Shared arrays require definitions/references, whereas inline fields use Obj. Give the original
// fixture objects independent shared IDs without making the fixture descriptions noisy. // fixture objects independent shared IDs without making the fixture descriptions noisy.
fn shared_objects(value: &mut F, next_id: &mut u32) { pub fn shared_objects(value: &mut F, next_id: &mut u32) {
match value { match value {
F::Shared(items) => { F::Shared(items) => {
for item in items { for item in items {
+70 -11
View File
@@ -11,7 +11,7 @@ mod support {
use support::text_fixture::*; use support::text_fixture::*;
use vectorcraft_affinity::{ use vectorcraft_affinity::{
Document, Limits, Document, Limits,
model::{Align, Kind, Text}, model::{Align, Kind, Paragraph, Text},
paint::{Color, Paint}, paint::{Color, Paint},
synth::{F, tag}, synth::{F, tag},
}; };
@@ -108,7 +108,7 @@ fn mixed_paragraph_alignments_are_explicitly_reported() {
let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]); let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]);
assert_eq!(content(text(&d)), "one\u{2029}two"); assert_eq!(content(text(&d)), "one\u{2029}two");
assert_eq!(text(&d).align, Align::Right); assert_eq!(text(&d).align, Align::Right);
assert!(warning(&d, "mixed paragraph alignments")); assert!(warning(&d, "mixed paragraph formats"));
} }
#[test] #[test]
@@ -194,10 +194,22 @@ fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
assert_eq!(texts.len(), 2); assert_eq!(texts.len(), 2);
assert!(texts[0].runs[0].all_caps); assert!(texts[0].runs[0].all_caps);
assert_eq!(texts[1].runs[0].features, ["smcp"]); assert_eq!(texts[1].runs[0].features, ["smcp"]);
for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] { // Indents and paragraph spacing are read (values in document pixels, as these fixtures set
// them in Affinity's paragraph panel).
let paragraphs = |name: &str| -> Vec<Paragraph> {
let d = load(name); let d = load(name);
assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings); assert!(!warning(&d, "paragraph"), "{name}: {:?}", d.warnings);
} d.spreads[0]
.nodes
.iter()
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None })
.flat_map(|t| t.runs.iter().map(|r| r.paragraph))
.collect()
};
let p = paragraphs("patchy-text-indent.af");
assert_eq!(p.iter().map(|p| (p.left, p.first, p.space_after)).collect::<Vec<_>>(), [(40.0, 0.0, 12.0), (0.0, 25.0, 12.0)]);
let p = paragraphs("patchy-text-para-spacing.af");
assert_eq!(p.iter().map(|p| (p.space_before, p.space_after)).collect::<Vec<_>>(), [(9.0, 17.0)]);
} }
proptest::proptest! { proptest::proptest! {
@@ -247,9 +259,19 @@ fn excessive_feature_settings_are_bounded_and_reported() {
assert!(warning(&d, "more than 256 text feature settings")); assert!(warning(&d, "more than 256 text feature settings"));
} }
#[test]
fn font_names_longer_than_256_bytes_are_dropped() {
// Each paragraph and run carries its font's name: a huge one would be copied with each.
let d = read(vec![block(utf8("x"), vec![run(1, Some(attrs(&"F".repeat(257), "Inter-Regular", 400, false, 12.0)))], &[])]);
let r = &text(&d).runs[0];
assert!(r.family.is_empty() && r.postscript == "Inter-Regular");
assert!(warning(&d, "font names longer than 256 bytes"));
}
#[test] #[test]
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() { fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0); // The longest font names kept (see `font_names_longer_than_256_bytes_are_dropped`).
let mut a = attrs(&"F".repeat(256), &"P".repeat(256), 400, false, 12.0);
if let F::Obj(_, fields) = &mut a { if let F::Obj(_, fields) = &mut a {
let settings = F::Obj( let settings = F::Obj(
tag(b"OtAt"), tag(b"OtAt"),
@@ -266,11 +288,12 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
} }
let F::Obj(class, fields) = a else { panic!("attributes must be an object") }; let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))]; let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42))))); // About 7.7 kB of names and features per run: the budget runs out after about 2200 runs.
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]); runs.extend((2..=4000).map(|end| run(end, Some(F::Ref(42)))));
let d = read(vec![block(utf8(&"x".repeat(4000)), runs, &[])]);
let t = text(&d); let t = text(&d);
assert_eq!(content(t), "x".repeat(1000)); assert_eq!(content(t), "x".repeat(4000));
assert_eq!(t.runs.len(), 1000); assert_eq!(t.runs.len(), 4000);
let metadata: usize = t let metadata: usize = t
.runs .runs
.iter() .iter()
@@ -279,7 +302,7 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
.map(|s| s.len() + std::mem::size_of::<String>()) .map(|s| s.len() + std::mem::size_of::<String>())
.sum(); .sum();
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes"); assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
assert_eq!(t.runs[0].family.len(), 16 * 1024); assert_eq!(t.runs[0].family.len(), 256);
assert_eq!(t.runs[0].features.len(), 256); assert_eq!(t.runs[0].features.len(), 256);
assert!(t.runs.last().unwrap().family.is_empty()); assert!(t.runs.last().unwrap().family.is_empty());
assert!(t.runs.last().unwrap().features.is_empty()); assert!(t.runs.last().unwrap().features.is_empty());
@@ -320,3 +343,39 @@ fn shared_gradient_attributes_are_bounded_without_losing_characters() {
assert!(warning(&d, "text attribute budget exceeded")); assert!(warning(&d, "text attribute budget exceeded"));
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent"); assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
} }
#[test]
fn shared_gradient_outlines_are_bounded_without_losing_characters() {
let color =
F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]);
let mut colors = vec![color];
colors.extend((1..1024).map(|_| F::Ref(43)));
let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
let pen = F::Obj(
tag(b"FilG"),
vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))],
);
let style = F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]);
// `Objs` 1 and 2: the outline's paint and line style, shared by every run.
let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![solid(1.0), pen, style]))]);
let mut runs = vec![run(1, Some(attrs))];
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
let t = text(&d);
assert_eq!(content(t), "x".repeat(1000));
assert_eq!(t.runs.len(), 1000);
let stop_count: usize = t
.runs
.iter()
.filter_map(|r| r.stroke.as_ref())
.map(|s| match &s.paint {
Paint::Gradient(g) => g.stops.len(),
_ => 0,
})
.sum();
assert!(stop_count * std::mem::size_of::<vectorcraft_affinity::paint::Stop>() <= 16 << 20);
let Some(Paint::Gradient(first)) = t.runs[0].stroke.as_ref().map(|s| &s.paint) else { panic!("the first outline must fit") };
assert_eq!(first.stops.len(), 1024);
assert!(t.runs.last().unwrap().stroke.is_none());
assert!(warning(&d, "text attribute budget exceeded"));
}
+6 -3
View File
@@ -33,6 +33,9 @@ const WIDE_LINES: &str =
const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)"; const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)";
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole"; const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)"; const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)";
/// The warning for an image left out because it can't be decoded: the note raster exports give
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
/// One segment of a subpath, cubics for every curve. /// One segment of a subpath, cubics for every curve.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -973,8 +976,8 @@ impl<'a> Scene<'a> {
/// Image `key` as a file in the chosen format, and its size in pixels. /// Image `key` as a file in the chosen format, and its size in pixels.
fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> { fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> {
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?; let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?;
let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?; let img = image::load_from_memory(&blob.bytes).map_err(|_| UNDECODED.to_string())?;
let size = (img.width(), img.height()); let size = (img.width(), img.height());
let mut bytes = vec![]; let mut bytes = vec![];
let written = match self.o.raster { let written = match self.o.raster {
@@ -992,7 +995,7 @@ impl<'a> Scene<'a> {
rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90)) rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90))
} }
}; };
written.map_err(|e| format!("image '{key}' could not be written: {e}"))?; written.map_err(|e| format!("an image could not be written: {e}"))?;
Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size)) Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size))
} }
} }
+18
View File
@@ -526,6 +526,24 @@ fn images_link_png_or_jpeg_files() {
assert!(r12.warnings.iter().any(|w| w.contains("no images"))); assert!(r12.warnings.iter().any(|w| w.contains("no images")));
} }
#[test]
fn images_without_pixels_are_left_out_with_one_note() {
// One image has no data in the document, and the other image's data is empty.
let mut d = doc_with(|d| {
["raster-1", "raster-2"]
.into_iter()
.map(|key| {
let im = ImageObject { key: key.into(), width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default() };
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
})
.collect()
});
d.images.insert("raster-2".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![]), proxy: None });
let out = export(&d, &opts(&d)).unwrap();
assert!(entities(&out).is_empty() && out.images.is_empty());
assert_eq!(out.warnings, ["an image has no pixels and was left out"]);
}
#[test] #[test]
fn crop_leaves_out_art_outside_the_region() { fn crop_leaves_out_art_outside_the_region() {
let d = shapes_doc(); let d = shapes_doc();
+394 -11
View File
@@ -31,6 +31,75 @@ impl GradientKind {
} }
} }
/// How a gradient mixes the colours between its stops (the Gradient panel's Method).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum GradientMethod {
/// Mixed in display RGB, as gradients always were.
#[default]
Classic,
/// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to
/// yellow passes through a light blue-grey rather than a dull mid grey.
Perceptual,
}
impl GradientMethod {
/// Parse a method name (`classic`, `perceptual`; any case).
pub fn parse(s: &str) -> Option<Self> {
[Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
Self::Classic => "Classic",
Self::Perceptual => "Perceptual",
}
}
pub fn is_classic(&self) -> bool {
*self == Self::Classic
}
/// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an
/// RGB colour between the two).
pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color {
match self {
Self::Classic => Color::lerp(a, b, t),
Self::Perceptual => {
use crate::libraries::{oklab_to_srgb, srgb_to_oklab};
let (p, q, t) = (srgb_to_oklab(a.to_rgb()), srgb_to_oklab(b.to_rgb()), f64::from(t));
let [r, g, bl] = oklab_to_srgb(p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t);
let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 };
Color::rgb(c(r), c(g), c(bl))
}
}
}
}
/// How far (display RGB, 0..1) the straight line between a Perceptual gradient's neighbouring
/// expanded stops may stray from the perceptual mix: under half a colour level.
const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0;
/// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is
/// halved to keep within [`PERCEPTUAL_TOLERANCE`].
const PERCEPTUAL_DEPTH: u32 = 10;
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order.
fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec<f32>) {
let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb();
let (c0, c1) = (at(v0), at(v1));
let stray = [0.25, 0.5, 0.75]
.into_iter()
.flat_map(|u: f32| {
let c = at(v0 + (v1 - v0) * u);
(0..3).map(move |k| (c0[k] + (c1[k] - c0[k]) * u - c[k]).abs())
})
.fold(0.0, f32::max);
if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE {
let m = (v0 + v1) / 2.0;
perceptual_steps(a, b, (v0, m), depth + 1, out);
out.push(m);
perceptual_steps(a, b, (m, v1), depth + 1, out);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop { pub struct GradientStop {
/// 0..=1 along the gradient. /// 0..=1 along the gradient.
@@ -75,19 +144,39 @@ fn half() -> f32 {
pub struct Gradient { pub struct Gradient {
pub kind: GradientKind, pub kind: GradientKind,
pub stops: Vec<GradientStop>, pub stops: Vec<GradientStop>,
/// Dither: a fill of this gradient is drawn as pixels with fine noise of up to a colour level
/// added, which hides the steps (banding) between the levels 8-bit colour has.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dither: bool,
/// How the colours between the stops are mixed (Classic unless set; written only when not).
#[serde(default, skip_serializing_if = "GradientMethod::is_classic")]
pub method: GradientMethod,
} }
impl Default for Gradient { impl Default for Gradient {
/// Illustrator's default "White, Black" gradient. /// Illustrator's default "White, Black" gradient.
fn default() -> Self { fn default() -> Self {
Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] } Self::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)])
} }
} }
impl Gradient { impl Gradient {
/// Colour and opacity at `t` (honours midpoints). /// A gradient of `kind` through `stops`, with the default options.
pub fn new(kind: GradientKind, stops: Vec<GradientStop>) -> Self {
Self { kind, stops, dither: false, method: GradientMethod::Classic }
}
/// Colour and opacity at `t` (honours midpoints and the method).
pub fn sample(&self, t: f32) -> (Color, f32) { pub fn sample(&self, t: f32) -> (Color, f32) {
self.sample_with(t, Color::lerp) self.sample_with(t, |a, b, v| self.method.mix(a, b, v))
}
/// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it
/// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual
/// gradient's as [`Self::sample`] mixes them.
pub fn sample_keep_model(&self, t: f32) -> (Color, f32) {
match self.method {
GradientMethod::Classic => self.sample_with(t, Color::lerp_keep_model),
m => self.sample_with(t, |a, b, v| m.mix(a, b, v)),
}
} }
/// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour /// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour
/// model rather than display RGB). /// model rather than display RGB).
@@ -115,20 +204,47 @@ impl Gradient {
None => (Color::BLACK, 1.0), None => (Color::BLACK, 1.0),
} }
} }
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints). /// Stops expanded so that midpoints are represented as explicit stops (for renderers without
/// midpoints), and a Perceptual gradient's segments as stops on its curve (for renderers that
/// mix linearly; within [`PERCEPTUAL_TOLERANCE`] of it).
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> { pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
self.expanded().map(|(t, c, o, _)| (t, c, o)).collect() self.expanded().map(|(t, c, o, _)| (t, c, o)).collect()
} }
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None` /// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
/// on the gradient's own stops). /// on the gradient's own stops and a Perceptual gradient's other in-between stops).
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ { pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
self.stops.iter().enumerate().flat_map(move |(i, s)| { self.stops.iter().enumerate().flat_map(move |(i, s)| {
let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| { let next = self.stops.get(i + 1);
let off_centre = (s.midpoint - 0.5).abs() > 1e-3;
// Classic: a stop at the midpoint, when it is off-centre.
let classic = next.filter(|_| off_centre && self.method.is_classic()).map(|n| {
let t = s.offset + (n.offset - s.offset) * s.midpoint; let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = self.sample(t); let (c, o) = self.sample(t);
(t, c, o, Some(s.midpoint)) (t, c, o, Some(s.midpoint))
}); });
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
// midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
let mut vs = Vec::new();
perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs);
vs.push(0.5);
perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs);
let m = s.midpoint.clamp(0.01, 0.99);
vs.into_iter().map(move |v| {
let mid = v == 0.5;
let u = if mid {
s.midpoint
} else if v < 0.5 {
2.0 * v * m
} else {
m + (v - 0.5) * 2.0 * (1.0 - m)
};
let t = s.offset + (n.offset - s.offset) * u;
let o = s.opacity + (n.opacity - s.opacity) * v;
(t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint))
})
});
std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten())
}) })
} }
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
@@ -172,7 +288,7 @@ impl Gradient {
); );
} }
stops.push(stop(1.0, to)); stops.push(stop(1.0, to));
Gradient { kind: GradientKind::Linear, stops } Gradient::new(GradientKind::Linear, stops)
} }
} }
@@ -534,6 +650,117 @@ impl GradientPaint {
f.transform(a); f.transform(a);
} }
} }
/// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill
/// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a
/// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix
/// linearly between the stops, as SVG's and PDF's gradients do)?
pub fn draws_as_pixels(&self, fill: bool) -> bool {
self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic()
}
/// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of
/// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours
/// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a
/// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets
/// [`dither_noise`] before it is rounded to a level.
pub fn pixels(
&self,
bounds: Rect,
area: Rect,
cols: u16,
rows: u16,
fill: bool,
rgb: &dyn Fn(&Color) -> [f32; 3],
) -> impl Iterator<Item = [u8; 4]> + use<> {
self.pixels_within(bounds, area, cols, rows, [0..cols, 0..rows], fill, rgb)
}
/// [`Self::pixels`] for the cells in columns `cells[0]` and rows `cells[1]` of the grid only,
/// row by row: the same pixels, noise and all, the whole grid has there.
#[allow(clippy::too_many_arguments)]
pub fn pixels_within(
&self,
bounds: Rect,
area: Rect,
cols: u16,
rows: u16,
cells: [std::ops::Range<u16>; 2],
fill: bool,
rgb: &dyn Fn(&Color) -> [f32; 3],
) -> impl Iterator<Item = [u8; 4]> + use<> {
let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither);
let ramp = Ramp::new(&self.gradient, rgb);
let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1)));
let (x0, y0) = (area.x0, area.y0);
let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end));
down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| {
let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch);
let c = ramp.at(geom.param_at(kind, p) as f32);
let level = |k: usize| {
let noise = if dither { dither_noise(i, j, k as u32) } else { 0.0 };
(c[k].clamp(0.0, 1.0) * 255.0 + noise).round().clamp(0.0, 255.0) as u8
};
[level(0), level(1), level(2), (c[3].clamp(0.0, 1.0) * 255.0).round() as u8]
})
}
}
/// A gradient's colours ready to be sampled pixel by pixel, in a colour space of the caller's
/// (display RGB, or an ink's coverage): its stops, midpoints and a Perceptual gradient's
/// in-between colours as stops (as [`Gradient::expanded_stops`]), each turned into that space
/// once, with colour and opacity mixed linearly between them, each on its own (as the renderer's
/// vector gradients mix them).
pub struct Ramp {
stops: Vec<(f32, [f32; 4])>,
}
impl Ramp {
pub fn new(g: &Gradient, rgb: &dyn Fn(&Color) -> [f32; 3]) -> Self {
let stops = g
.expanded()
.map(|(t, c, o, _)| {
let [r, gr, b] = rgb(&c);
(t, [r, gr, b, o])
})
.collect();
Self { stops }
}
/// Unpremultiplied colour and opacity at `t`, padded past the ends (transparent black
/// without stops).
pub fn at(&self, t: f32) -> [f32; 4] {
let Some(&(t0, first)) = self.stops.first() else { return [0.0; 4] };
// Not after the first stop (or not a number): its colour.
if t.partial_cmp(&t0) != Some(std::cmp::Ordering::Greater) {
return first;
}
for w in self.stops.windows(2) {
let ((a, ca), (b, cb)) = (w[0], w[1]);
if t <= b {
let u = if b - a > 1e-9 { (t - a) / (b - a) } else { 1.0 };
return std::array::from_fn(|k| ca[k] + (cb[k] - ca[k]) * u);
}
}
self.stops.last().map_or([0.0; 4], |s| s.1)
}
}
/// Seed of [`dither_noise`]: fixed, so a dithered gradient draws and exports the same pixels on
/// every run.
const DITHER_SEED: u32 = 0x5eed_d17e;
/// An integer hash that mixes every input bit into every output bit (the "lowbias32" mix).
fn mix32(mut v: u32) -> u32 {
v ^= v >> 16;
v = v.wrapping_mul(0x7feb_352d);
v ^= v >> 15;
v = v.wrapping_mul(0x846c_a68b);
v ^ (v >> 16)
}
/// Dither noise for colour channel `channel` of pixel (`x`, `y`), in colour levels: -1..1,
/// densest around 0 (the sum of two uniform values: noise that hides steps between levels without
/// a pattern of its own), the same for the same pixel on every run.
pub fn dither_noise(x: u32, y: u32, channel: u32) -> f32 {
let h = mix32(x ^ mix32(y ^ mix32(channel ^ DITHER_SEED)));
((h & 0xffff) as f32 + (h >> 16) as f32) / 65535.0 - 1.0
} }
#[cfg(test)] #[cfg(test)]
@@ -582,9 +809,8 @@ mod tests {
#[test] #[test]
fn reverse_mirrors_midpoints() { fn reverse_mirrors_midpoints() {
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] }; let two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]);
let three = let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]);
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] };
for g in [two, three] { for g in [two, three] {
let mut r = g.clone(); let mut r = g.clone();
r.reverse(); r.reverse();
@@ -825,4 +1051,161 @@ mod tests {
h.keep_focal_from(&before); h.keep_focal_from(&before);
assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}"); assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}");
} }
#[test]
fn pixels_sample_the_ramp_at_each_cell_centre() {
// White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end.
let mut g = GradientPaint::new(Gradient::default());
g.gradient.stops[0].midpoint = 0.3;
g.gradient.stops[1].opacity = 0.5;
let b = Rect::new(0.0, 0.0, 10.0, 2.0);
let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect();
assert_eq!(px.len(), 20);
let ramp = Ramp::new(&g.gradient, &Color::to_rgb);
for (i, p) in px.iter().take(10).enumerate() {
let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8);
assert_eq!(*p, c, "pixel {i}");
}
assert_eq!(px[3], px[13], "rows alike");
// The midpoint stop: grey at 30%; padded past the ends.
assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3));
assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]);
assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]);
assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]);
}
fn blue_yellow(method: GradientMethod) -> Gradient {
let mut g = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))],
);
g.method = method;
g
}
/// Illustrator 30's Perceptual method mixes colours in a perceptually even way
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html).
/// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey
/// a display RGB mix gives, and its lightness climbs evenly.
#[test]
fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() {
let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb();
assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}");
let p = blue_yellow(GradientMethod::Perceptual);
let [r, g, b] = p.sample(0.5).0.to_rgb();
assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]);
assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]);
let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0];
let steps: Vec<f64> = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect();
assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}");
// Ends and opacity as before.
assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff");
assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00");
}
/// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual
/// curve (the midpoint diamond among them), more where it bends, close enough that the linear
/// ramp between them stays within half a colour level of the curve; a segment that hardly
/// bends needs few.
#[test]
fn perceptual_segments_expand_into_stops_on_the_curve() {
let pairs = [
(Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)),
(Color::BLACK, Color::rgb(1.0, 0.0, 0.0)),
(Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)),
(Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE),
];
for (a, b) in pairs {
let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]);
p.method = GradientMethod::Perceptual;
p.stops[0].midpoint = 0.3;
p.stops[1].opacity = 0.5;
let ex: Vec<_> = p.expanded().collect();
assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len());
assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order");
assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint");
assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6));
let ramp = Ramp::new(&p, &Color::to_rgb);
for k in 0..=2000 {
let t = k as f32 / 2000.0;
let (c, o) = p.sample(t);
let [r, g, b] = c.to_rgb();
let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}");
}
}
let mut flat = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))],
);
flat.method = GradientMethod::Perceptual;
assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint");
}
/// Classic gradients expand as they always did: their stops, and a stop at each off-centre
/// midpoint, sampled in display RGB (so the renderer draws the same pixels as before).
#[test]
fn classic_gradients_expand_as_before() {
let mut g = Gradient::new(
GradientKind::Linear,
vec![
GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) },
GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) },
GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) },
GradientStop::new(1.0, Color::gray(0.3)),
],
);
// The expansion gradients had before they had a method.
let before: Vec<_> = g
.stops
.iter()
.enumerate()
.flat_map(|(i, s)| {
let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = g.sample_with(t, Color::lerp);
(t, c, o, Some(s.midpoint))
});
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
})
.collect();
assert_eq!(g.expanded().collect::<Vec<_>>(), before);
assert_eq!(before.len(), 6);
assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp));
assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model));
g.method = GradientMethod::Perceptual;
assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix");
}
/// The method is written only when Perceptual, so files from before it (and Classic
/// gradients) read as Classic and new Classic files stay as they were.
#[test]
fn method_is_saved_only_when_perceptual() {
let classic = serde_json::to_value(Gradient::default()).unwrap();
assert!(classic.get("method").is_none(), "{classic}");
let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap();
assert_eq!(old.method, GradientMethod::Classic);
let p = blue_yellow(GradientMethod::Perceptual);
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["method"], "Perceptual");
assert_eq!(serde_json::from_value::<Gradient>(v).unwrap(), p);
assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual));
assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic));
assert_eq!(GradientMethod::parse("smooth"), None);
}
/// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there,
/// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke,
/// dithered or not.
#[test]
fn perceptual_gradients_draw_as_pixels() {
for fill in [true, false] {
assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill));
assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill));
let mut radial = blue_yellow(GradientMethod::Perceptual);
radial.kind = GradientKind::Radial;
radial.dither = true;
assert!(GradientPaint::new(radial).draws_as_pixels(fill));
}
}
} }
+21 -1
View File
@@ -16,7 +16,7 @@ pub mod swatch;
pub use blend::BlendMode; pub use blend::BlendMode;
pub use freeform::{Freeform, FreeformMode, FreeformPoint}; pub use freeform::{Freeform, FreeformMode, FreeformPoint};
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop};
pub use libraries::SwatchLibrary; pub use libraries::SwatchLibrary;
pub use swatch::{Swatch, SwatchGroup, default_swatches}; pub use swatch::{Swatch, SwatchGroup, default_swatches};
@@ -146,6 +146,8 @@ impl Color {
} else { } else {
60.0 * ((r - g) / d + 4.0) 60.0 * ((r - g) / d + 4.0)
}; };
// f32 rounding in rem_euclid can yield exactly 360; wrap back to 0.
let h = if h >= 360.0 { 0.0 } else { h };
let s = if max <= 0.0 { 0.0 } else { d / max }; let s = if max <= 0.0 { 0.0 } else { d / max };
[h, s, max] [h, s, max]
} }
@@ -378,6 +380,18 @@ impl Paint {
_ => None, _ => None,
} }
} }
/// This stroke paint as the fill of the stroke's outline, so the outline looks as the stroke
/// did: without dither, which strokes ignore ([`GradientPaint::draws_as_pixels`]).
pub fn stroke_as_fill(&self) -> Paint {
match self {
Paint::Gradient(g) if g.gradient.dither => {
let mut g = g.clone();
g.gradient.dither = false;
Paint::Gradient(g)
}
p => p.clone(),
}
}
/// The Registration colour: 100% of every ink, linked to [`swatch::REGISTRATION`]. /// The Registration colour: 100% of every ink, linked to [`swatch::REGISTRATION`].
pub fn registration() -> Self { pub fn registration() -> Self {
Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(swatch::REGISTRATION.into()), tint: 1.0 } Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(swatch::REGISTRATION.into()), tint: 1.0 }
@@ -426,6 +440,12 @@ impl Paint {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn hue_below_360() {
let h = Color::Rgb { r: 1.0, g: 0.0, b: 1e-7 }.to_hsb()[0];
assert!((0.0..360.0).contains(&h), "hue {h}");
}
#[test] #[test]
fn hex_roundtrip() { fn hex_roundtrip() {
let c = Color::from_hex("#ff8000").unwrap(); let c = Color::from_hex("#ff8000").unwrap();
+8 -3
View File
@@ -98,7 +98,7 @@ pub fn builtin_library(id: &str) -> Option<Arc<SwatchLibrary>> {
// ---------- colour construction ---------- // ---------- colour construction ----------
/// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped. /// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped.
fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { pub(crate) fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b; let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b; let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b; let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
@@ -113,7 +113,12 @@ fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
/// OKLab `[l, a, b]` of a colour's display RGB. /// OKLab `[l, a, b]` of a colour's display RGB.
pub fn to_oklab(c: &Color) -> [f64; 3] { pub fn to_oklab(c: &Color) -> [f64; 3] {
let [r, g, b] = c.to_rgb_uncalibrated().map(|v| { srgb_to_oklab(c.to_rgb_uncalibrated())
}
/// OKLab `[l, a, b]` of sRGB `rgb` (0..=1, gamma encoded).
pub(crate) fn srgb_to_oklab(rgb: [f32; 3]) -> [f64; 3] {
let [r, g, b] = rgb.map(|v| {
let v = f64::from(v); let v = f64::from(v);
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}); });
@@ -171,7 +176,7 @@ fn group(name: &str, swatches: Vec<Swatch>) -> SwatchGroup {
/// A gradient swatch through `stops` of (offset, colour, opacity). /// A gradient swatch through `stops` of (offset, colour, opacity).
pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch { pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch {
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect(); let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect();
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false } Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops)))), global: false, spot: false }
} }
/// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1), /// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1),
+7 -7
View File
@@ -721,13 +721,13 @@ mod tests {
} }
fn sample() -> SwatchLibrary { fn sample() -> SwatchLibrary {
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient { let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
kind: GradientKind::Linear, GradientKind::Linear,
stops: vec![ vec![
GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) }, GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) },
GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) }, GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) },
], ],
}))); ))));
SwatchLibrary { SwatchLibrary {
name: "Brand */ Colours".into(), name: "Brand */ Colours".into(),
swatches: vec![ swatches: vec![
@@ -822,15 +822,15 @@ mod tests {
name: "Positions".into(), name: "Positions".into(),
swatches: vec![Swatch { swatches: vec![Swatch {
name: "Ramp".into(), name: "Ramp".into(),
paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
kind, kind,
stops: vec![ vec![
GradientStop::new(0.0, Color::BLACK), GradientStop::new(0.0, Color::BLACK),
GradientStop::new(0.125, Color::rgb8(255, 0, 0)), GradientStop::new(0.125, Color::rgb8(255, 0, 0)),
GradientStop::new(0.12890625, Color::WHITE), GradientStop::new(0.12890625, Color::WHITE),
GradientStop::new(1.0, Color::rgb8(0, 0, 255)), GradientStop::new(1.0, Color::rgb8(0, 0, 255)),
], ],
}))), )))),
global: false, global: false,
spot: false, spot: false,
}], }],
+3 -3
View File
@@ -123,13 +123,13 @@ fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
.collect(); .collect();
let grad = |name: &str, a: &str, b: &str, kind: GradientKind| Swatch { let grad = |name: &str, a: &str, b: &str, kind: GradientKind| Swatch {
name: name.into(), name: name.into(),
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient { paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient::new(
kind, kind,
stops: vec![ vec![
GradientStop::new(0.0, Color::from_hex(a).unwrap_or(Color::WHITE)), GradientStop::new(0.0, Color::from_hex(a).unwrap_or(Color::WHITE)),
GradientStop::new(1.0, Color::from_hex(b).unwrap_or(Color::BLACK)), GradientStop::new(1.0, Color::from_hex(b).unwrap_or(Color::BLACK)),
], ],
}))), )))),
global: false, global: false,
spot: false, spot: false,
}; };
+1
View File
@@ -20,6 +20,7 @@ zune-jpeg = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
chrono = { workspace = true }
[dev-dependencies] [dev-dependencies]
jpeg-encoder = { workspace = true } jpeg-encoder = { workspace = true }
+69 -11
View File
@@ -89,7 +89,7 @@ fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
out.stops = offsets out.stops = offsets
.iter() .iter()
.map(|&o| { .map(|&o| {
let (color, opacity) = g.sample_with(o, lerp_color); let (color, opacity) = g.sample_keep_model(o);
let (swatch, tint) = link_at(g, o); let (swatch, tint) = link_at(g, o);
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) } GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
}) })
@@ -579,11 +579,18 @@ impl Lerp {
/// The object at `t` (0 = `a`, 1 = `b`), with id 0. /// The object at `t` (0 = `a`, 1 = `b`), with id 0.
pub fn at(&self, t: f64) -> Node { pub fn at(&self, t: f64) -> Node {
self.at_eased(t, t)
}
/// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text
/// colours) are at `tc`: a blend with its colours eased apart from its spacing.
pub fn at_eased(&self, t: f64, tc: f64) -> Node {
let (a, b) = (&self.a, &self.b); let (a, b) = (&self.a, &self.b);
let base = if t < 0.5 { a } else { b }; let base = if t < 0.5 { a } else { b };
let mut n = match &self.shape { let mut n = match &self.shape {
Shape::Group { children, clip } => { Shape::Group { children, clip } => {
let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip }); let mut g =
Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip });
g.isolate = base.isolate; g.isolate = base.isolate;
g.knockout = base.knockout; g.knockout = base.knockout;
g g
@@ -593,8 +600,8 @@ impl Lerp {
if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) { if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) {
for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) { for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) {
p.p = x.p.lerp(y.p, t); p.p = x.p.lerp(y.p, t);
p.color = lerp_color(&x.color, &y.color, t as f32); p.color = lerp_color(&x.color, &y.color, tc as f32);
p.opacity = lerp_f32(x.opacity, y.opacity, t as f32); p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32);
for h in 0..4 { for h in 0..4 {
p.handles[h] = x.handles[h].lerp(y.handles[h], t); p.handles[h] = x.handles[h].lerp(y.handles[h], t);
} }
@@ -620,7 +627,7 @@ impl Lerp {
Shape::Placed => { Shape::Placed => {
let mut n = base.clone(); let mut n = base.clone();
match (&mut n.kind, &a.kind, &b.kind) { match (&mut n.kind, &a.kind, &b.kind) {
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t), (NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc),
(NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => { (NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => {
*o = lerp_affine(*x, *y, t) *o = lerp_affine(*x, *y, t)
} }
@@ -657,8 +664,8 @@ impl Lerp {
} }
}; };
n.id = NodeId(0); n.id = NodeId(0);
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into(); n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into();
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32); n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32);
n.blend = base.blend; n.blend = base.blend;
n.visible = true; n.visible = true;
n.locked = false; n.locked = false;
@@ -688,7 +695,7 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an /// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at /// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
/// the same place (by character) of both. /// the same place (by character) of both.
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) { fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
o.xf = lerp_affine(x.xf, y.xf, t); o.xf = lerp_affine(x.xf, y.xf, t);
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) = if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
(&mut o.kind, &x.kind, &y.kind) (&mut o.kind, &x.kind, &y.kind)
@@ -710,7 +717,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
} }
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1); let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
let mut start = 0usize; let mut start = 0usize;
let tf = t as f32; let tf = tc as f32;
let mut resized = false; let mut resized = false;
for r in &mut o.runs { for r in &mut o.runs {
let len = r.text.chars().count(); let len = r.text.chars().count();
@@ -1071,8 +1078,10 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
let n = blend_step_count(a, b, spec.spacing, len); let n = blend_step_count(a, b, spec.spacing, len);
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1))); let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
for j in 1..=n { for j in 1..=n {
let t = j as f64 / (n + 1) as f64; // Even position of the step, then where the easing puts its shape and its colours.
let mut s = lerp.at(t); let u = j as f64 / (n + 1) as f64;
let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u));
let mut s = lerp.at_eased(t, tc);
if let Some(r) = &rail { if let Some(r) = &rail {
r.place(&mut s, i, t); r.place(&mut s, i, t);
} }
@@ -1134,6 +1143,55 @@ mod tests {
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
} }
#[test]
fn easing_keeps_the_ends_and_stays_monotone() {
use crate::live::{BlendEase, BlendEaseType as K};
for kind in [K::In, K::Out, K::InOut, K::OutIn] {
for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] {
let e = BlendEase { kind, ramp };
assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0));
let ys: Vec<f64> = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect();
assert!(ys.windows(2).all(|w| w[0] <= w[1]));
assert_eq!(e.apply(f64::NAN), 0.0);
if ramp == 0.0 {
assert_eq!(e.apply(0.25), 0.25);
}
}
}
let e = |kind| BlendEase { kind, ramp: 1.0 };
assert!(e(K::In).apply(0.25) < 0.25);
assert!(e(K::Out).apply(0.25) > 0.25);
assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75);
assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75);
}
#[test]
fn colour_easing_moves_colours_apart_from_positions() {
let rect = |x: f64, c: Color| {
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default());
n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into();
Arc::new(n)
};
let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)];
let mid = |spec: &BlendSpec| {
let out = blend_expand(&keys, spec);
let n = &out[1];
(n.geometric_bounds().unwrap().x0, n.appearance.fill_paint())
};
// Position easing runs along a spine: Align to Path gives the blend one.
let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() };
let (x0, p0) = mid(&base);
// Colour easing alone leaves the position and moves the colour.
let colour = BlendSpec { color_shift: 50.0, ..base.clone() };
let (x1, p1) = mid(&colour);
assert!((x0 - x1).abs() < 1e-9 && p0 != p1);
// Position easing alone leaves the colour and moves the position.
let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() };
let (x2, p2) = mid(&pos);
assert!((x0 - x2).abs() > 1e-3 && p0 == p2);
assert_eq!(mid(&pos).0, x2, "deterministic");
}
#[test] #[test]
fn affine_lerp_turns_the_short_way() { fn affine_lerp_turns_the_short_way() {
let x = Affine::rotate(170f64.to_radians()); let x = Affine::rotate(170f64.to_radians());
+132 -2
View File
@@ -99,7 +99,7 @@ pub use graph::{
}; };
pub use hit::{Hit, HitKind}; pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions}; pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use live_paint::{GapOptions, GapSize}; pub use live_paint::{GapOptions, GapSize};
pub use metadata::{CopyrightStatus, DocMetadata}; pub use metadata::{CopyrightStatus, DocMetadata};
pub use node::Knockout; pub use node::Knockout;
@@ -109,7 +109,7 @@ pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS};
pub use orient::OrientedBox; pub use orient::OrientedBox;
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use perspective::PerspectiveAttachment; pub use perspective::PerspectiveAttachment;
pub use placed_document::PlacedDocument; pub use placed_document::{PageBox, PlacedDocument};
pub use profiles::ColorProfiles; pub use profiles::ColorProfiles;
pub use puppet::{PuppetPin, PuppetPins}; pub use puppet::{PuppetPin, PuppetPins};
pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
@@ -935,6 +935,136 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
pub const FORMAT_VERSION: u32 = 1; pub const FORMAT_VERSION: u32 = 1;
impl Document { impl Document {
/// Is everything but the layers the same in `other`, as the next version of the document after
/// an edit of its art? The id counter and the id index are left out, and art outside the layers
/// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it
/// doesn't change: quick for every frame of an animation.
pub fn same_outside_layers(&self, other: &Self) -> bool {
let same_art = |a: &[Arc<Node>], b: &[Arc<Node>]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b));
let same_pattern = |a: &PatternDef, b: &PatternDef| {
same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() }
};
let Self {
version,
title,
template,
units,
ruler_origin,
rulers,
artboard_ruler_origins,
color_mode,
artboards,
layers: _,
swatches,
swatch_groups,
graphic_styles,
char_styles,
para_styles,
text_threads,
symbols,
graph_designs,
guides,
views,
saved_selections,
variables,
grid,
raster_effects_ppi,
images,
patterns,
pattern_edit,
mask_edit,
next_id: _,
unknown,
page_isolate,
page_knockout,
spot_use_lab,
setup,
paste_remembers_layers,
metadata,
raster_effects,
last_view,
transparency_grid,
layers_open,
color_profiles,
export_settings,
extra,
slices,
slices_clip_to_artboard,
print_setup,
assets,
svg_scripts,
puppet,
new_art,
dimensions,
index: _,
} = self;
*version == other.version
&& *title == other.title
&& *template == other.template
&& *units == other.units
&& *ruler_origin == other.ruler_origin
&& *rulers == other.rulers
&& *artboard_ruler_origins == other.artboard_ruler_origins
&& *color_mode == other.color_mode
&& *artboards == other.artboards
&& *swatches == other.swatches
&& *swatch_groups == other.swatch_groups
&& *graphic_styles == other.graphic_styles
&& *char_styles == other.char_styles
&& *para_styles == other.para_styles
&& *text_threads == other.text_threads
&& symbols.len() == other.symbols.len()
&& symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
&& graph_designs.len() == other.graph_designs.len()
&& graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
&& *guides == other.guides
&& *views == other.views
&& *saved_selections == other.saved_selections
&& *variables == other.variables
&& *grid == other.grid
&& *raster_effects_ppi == other.raster_effects_ppi
&& *images == other.images
&& patterns.len() == other.patterns.len()
&& patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b))
&& match (pattern_edit, &other.pattern_edit) {
(Some(a), Some(b)) => {
a.pattern == b.pattern
&& a.layer == b.layer
&& match (&a.original, &b.original) {
(Some(a), Some(b)) => same_pattern(a, b),
(a, b) => a.is_none() && b.is_none(),
}
}
(a, b) => a.is_none() && b.is_none(),
}
&& *mask_edit == other.mask_edit
&& *unknown == other.unknown
&& *page_isolate == other.page_isolate
&& *page_knockout == other.page_knockout
&& *spot_use_lab == other.spot_use_lab
&& *setup == other.setup
&& *paste_remembers_layers == other.paste_remembers_layers
&& *metadata == other.metadata
&& *raster_effects == other.raster_effects
&& *last_view == other.last_view
&& *transparency_grid == other.transparency_grid
&& *layers_open == other.layers_open
&& *color_profiles == other.color_profiles
&& *export_settings == other.export_settings
&& *extra == other.extra
&& *slices == other.slices
&& *slices_clip_to_artboard == other.slices_clip_to_artboard
&& *print_setup == other.print_setup
&& *assets == other.assets
&& *svg_scripts == other.svg_scripts
&& match (puppet, &other.puppet) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(a, b) => a.is_none() && b.is_none(),
}
&& *new_art == other.new_art
&& *dimensions == other.dimensions
}
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value /// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
/// isn't a number). /// isn't a number).
pub fn raster_ppi(&self) -> f64 { pub fn raster_ppi(&self) -> f64 {
+127 -4
View File
@@ -60,12 +60,75 @@ pub enum BlendOrientation {
AlignToPath, AlignToPath,
} }
/// Distribution of blend steps, as offered by the Blend panel's Type menu.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BlendEaseType {
#[default]
In,
Out,
InOut,
OutIn,
}
impl BlendEaseType {
pub fn id(self) -> &'static str {
match self {
Self::In => "easeIn",
Self::Out => "easeOut",
Self::InOut => "easeInOut",
Self::OutIn => "easeOutIn",
}
}
}
/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BlendEase {
#[serde(default)]
pub kind: BlendEaseType,
#[serde(default, deserialize_with = "blend_ramp")]
pub ramp: f64,
}
impl BlendEase {
pub fn is_linear(&self) -> bool {
self.ramp <= 0.0
}
/// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0.
pub fn is_default(&self) -> bool {
self.kind == BlendEaseType::In && self.ramp == 0.0
}
/// A monotone polynomial distribution, blended with even spacing by Ramp. Public help
/// describes the four distributions, not their numeric curves; these curves are original.
pub fn apply(&self, u: f64) -> f64 {
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 };
let eased = match self.kind {
BlendEaseType::In => u * u,
BlendEaseType::Out => u * (2.0 - u),
BlendEaseType::InOut => u * u * (3.0 - 2.0 * u),
BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u),
BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5),
};
u + r * (eased - u)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BlendSpec { pub struct BlendSpec {
#[serde(default)] #[serde(default)]
pub spacing: BlendSpacing, pub spacing: BlendSpacing,
#[serde(default)] #[serde(default)]
pub orientation: BlendOrientation, pub orientation: BlendOrientation,
/// Acceleration of the steps' shapes and positions along the spine between two keys.
#[serde(default, skip_serializing_if = "BlendEase::is_default")]
pub ease: BlendEase,
/// Independent color acceleration. -100..100, with 0 preserving the even color ramp.
#[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")]
pub color_shift: f64,
/// Replaced spine. `None` = the straight lines between the key objects' centres. /// Replaced spine. `None` = the straight lines between the key objects' centres.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub spine: Option<PathData>, pub spine: Option<PathData>,
@@ -228,12 +291,15 @@ pub struct GradientMesh {
pub points: Vec<MeshPoint>, pub points: Vec<MeshPoint>,
} }
/// One tessellated piece of a mesh (a small quad with a solid colour). /// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and
/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads
/// meet without a step.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct MeshQuad { pub struct MeshQuad {
pub pts: [Point; 4], pub pts: [Point; 4],
pub color: Color, pub color: Color,
pub opacity: f32, pub opacity: f32,
pub corners: [(Color, f32); 4],
} }
/// Create Gradient Mesh → Appearance. /// Create Gradient Mesh → Appearance.
@@ -769,7 +835,7 @@ impl GradientMesh {
(Color::rgb(rgb[0], rgb[1], rgb[2]), op) (Color::rgb(rgb[0], rgb[1], rgb[2]), op)
} }
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre. /// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners.
pub fn quads(&self, n: usize) -> Vec<MeshQuad> { pub fn quads(&self, n: usize) -> Vec<MeshQuad> {
if !self.is_valid() { if !self.is_valid() {
return vec![]; return vec![];
@@ -777,18 +843,27 @@ impl GradientMesh {
let n = n.clamp(1, 64); let n = n.clamp(1, 64);
let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n); let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n);
let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)]; let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)];
let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)];
for r in 0..self.rows as usize { for r in 0..self.rows as usize {
for c in 0..self.cols as usize { for c in 0..self.cols as usize {
for j in 0..=n { for j in 0..=n {
for i in 0..=n { for i in 0..=n {
grid[j * (n + 1) + i] = self.eval(r, c, i as f64 / n as f64, j as f64 / n as f64); let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
grid[j * (n + 1) + i] = self.eval(r, c, u, v);
colors[j * (n + 1) + i] = self.color_at(r, c, u, v);
} }
} }
for j in 0..n { for j in 0..n {
for i in 0..n { for i in 0..n {
let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64); let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64);
let g = |a: usize, b: usize| grid[b * (n + 1) + a]; let g = |a: usize, b: usize| grid[b * (n + 1) + a];
out.push(MeshQuad { pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], color, opacity }); let k = |a: usize, b: usize| colors[b * (n + 1) + a];
out.push(MeshQuad {
pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)],
color,
opacity,
corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)],
});
} }
} }
} }
@@ -1757,10 +1832,58 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxe
o o
} }
/// Reading a native file never fails over one bad blend number: out-of-range values clamp,
/// and anything that is not a finite number (NaN, null, text) becomes the default. The
/// commands still refuse such input (`ease_param`).
fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result<f64, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max)))
}
fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
blend_number(d, 0.0, 1.0)
}
fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
blend_number(d, -100.0, 100.0)
}
/// Whether an optional stored color shift has its unchanged default.
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
impl BlendSpec {
/// Shift the color transition independently of shape placement. The exponential curve is
/// original; the documented behavior specifies the direction, not numeric sample values.
pub fn color_position(&self, u: f64) -> f64 {
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 };
if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) }
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of
/// failing the whole document.
#[test]
fn blend_easing_numbers_clamp_when_read() {
use serde::de::value::{Error, F64Deserializer};
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap();
assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0));
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap();
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0));
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap();
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0));
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(blend_ramp(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
assert_eq!(blend_color_shift(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
}
}
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the /// #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 /// 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. /// real distance from the centre line; an inner edge that would pass the centre stops there.
+26 -15
View File
@@ -3,6 +3,7 @@
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved //! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
//! before File Info existed load unchanged. //! before File Info existed load unchanged.
use chrono::{Datelike, Timelike};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// File Info → Copyright Status. /// File Info → Copyright Status.
@@ -118,23 +119,26 @@ pub fn now_unix() -> Option<i64> {
None None
} }
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic /// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (proleptic Gregorian; years
/// Gregorian; years clamped to 0–9999). /// clamped to 0–9999).
pub fn civil(t: i64) -> [i64; 6] { pub fn civil(t: i64) -> [i64; 6] {
// Clamp first so every step stays in range for any input. // Clamp the input first so every input maps to a representable date (0000-01-01 .. 9999-12-31),
// exactly as the old code did. The clamped instant is always inside chrono's range, so the
// conversion below never falls back; the trailing `clamp(0, 9999)` is the old year clamp.
let t = t.clamp(-62_167_219_200, 253_402_300_799); let t = t.clamp(-62_167_219_200, 253_402_300_799);
let days = t.div_euclid(86_400); let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400));
let secs = t.rem_euclid(86_400); let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
let z = days + 719_468; // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let era = z.div_euclid(146_097); let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let doe = z - era * 146_097; let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; match (date, time) {
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); (Some(d), Some(tm)) => {
let mp = (5 * doy + 2) / 153; let year = i128::from(d.year()) + i128::from(cycles) * 400;
let day = doy - (153 * mp + 2) / 5 + 1; let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX });
let month = if mp < 10 { mp + 3 } else { mp - 9 }; [year.clamp(0, 9999), i64::from(d.month()), i64::from(d.day()), i64::from(tm.hour()), i64::from(tm.minute()), i64::from(tm.second())]
let year = yoe + era * 400 + i64::from(month <= 2); }
[year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60] _ => [1970, 1, 1, 0, 0, 0], // unreachable: the clamped `t` is always a valid date
}
} }
/// Unix seconds → `2026-10-05T14:03:00Z`. /// Unix seconds → `2026-10-05T14:03:00Z`.
@@ -151,8 +155,15 @@ mod tests {
fn dates_and_statuses() { fn dates_and_statuses() {
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z"); assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z"); assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z");
// The input is clamped to 0000-01-01..9999-12-31, then the year is clamped to 0..=9999,
// exactly as the old code did: inputs far past chrono's range land on the clamp bounds,
// never on the epoch.
assert_eq!(civil(i64::MIN)[0], 0); assert_eq!(civil(i64::MIN)[0], 0);
assert_eq!(civil(i64::MAX)[0], 9999); assert_eq!(civil(i64::MAX)[0], 9999);
assert_eq!(iso8601(i64::MIN), "0000-01-01T00:00:00Z");
assert_eq!(iso8601(i64::MAX), "9999-12-31T23:59:59Z");
assert_eq!(iso8601(10_000_000_000_000), "9999-12-31T23:59:59Z");
assert_eq!(iso8601(-10_000_000_000_000), "0000-01-01T00:00:00Z");
assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain)); assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain));
assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain)); assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain));
assert_eq!(CopyrightStatus::parse("maybe"), None); assert_eq!(CopyrightStatus::parse("maybe"), None);
+4 -6
View File
@@ -523,8 +523,8 @@ pub enum NodeKind {
Mesh(GradientMesh), Mesh(GradientMesh),
/// Live Repeat (radial / grid / mirror) of source art. /// Live Repeat (radial / grid / mirror) of source art.
Repeat(RepeatSpec), Repeat(RepeatSpec),
/// A placed document: an artboard of another VectorCraft file, linked and locked (see /// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai`
/// [`crate::placed_document`]). /// file, linked and locked (see [`crate::placed_document`]).
PlacedDocument(Box<crate::placed_document::PlacedDocument>), PlacedDocument(Box<crate::placed_document::PlacedDocument>),
/// Object › Intertwine: the objects keep their own stacking order, except where one of them /// Object › Intertwine: the objects keep their own stacking order, except where one of them
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see /// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
@@ -1301,10 +1301,8 @@ impl Node {
self.projected(Some(b)).map(|b| b.inflate(o, o)) self.projected(Some(b)).map(|b| b.inflate(o, o))
} }
NodeKind::Text(t) => { NodeKind::Text(t) => {
// The characters' strokes paint around the glyphs, scaled with the type. // The characters' strokes paint around the glyphs.
let k = Scaling::factor(t.xf).unwrap_or(1.0); let o = self.appearance.outset().max(t.char_stroke_outset());
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
let o = self.appearance.outset().max(chars);
self.geometric(symbols).map(|b| b.inflate(o, o)) self.geometric(symbols).map(|b| b.inflate(o, o))
} }
// An image is measured by its frame, without its own fills and strokes. // An image is measured by its frame, without its own fills and strokes.
+47 -1
View File
@@ -279,7 +279,7 @@ impl PatternDef {
out out
} }
/// Offsets of the pattern-editing preview copies (`copies`×`copies` around the tile, the /// Offsets of the pattern-editing preview copies (`copies`×`copies` around the tile, the
/// centre `(0, 0)` excluded). /// centre `(0, 0)` excluded), in row-major order (not paint order).
pub fn preview_offsets(&self) -> Vec<Vec2> { pub fn preview_offsets(&self) -> Vec<Vec2> {
let k = (self.copies.clamp(1, 15) / 2) as i64; let k = (self.copies.clamp(1, 15) / 2) as i64;
let mut out = vec![]; let mut out = vec![];
@@ -292,6 +292,38 @@ impl PatternDef {
} }
out out
} }
/// Offsets of every pattern-editing preview copy, including the centre tile, in the same paint
/// order [`offsets_covering`] uses for [`Overlap`].
pub fn preview_offsets_in_paint_order(&self) -> Vec<Vec2> {
let k = (self.copies.clamp(1, 15) / 2) as i64;
let (i0, i1, j0, j1) = (-k, k, -k, k);
let mut out = vec![];
let rows: Vec<i64> = if self.overlap.bottom_in_front { (j0..=j1).collect() } else { (j0..=j1).rev().collect() };
for &j in &rows {
let cols: Vec<i64> = if self.overlap.right_in_front { (i0..=i1).collect() } else { (i0..=i1).rev().collect() };
for i in cols {
out.push(self.offset(i, j));
}
}
out
}
/// Preview copies drawn before and after the centre tile in [`Self::preview_offsets_in_paint_order`],
/// for one dimmed composite each (the centre tile is drawn at full strength between them).
pub fn preview_offsets_dimmed_sides(&self) -> (Vec<Vec2>, Vec<Vec2>) {
let mut before = vec![];
let mut after = vec![];
let mut past_center = false;
for o in self.preview_offsets_in_paint_order() {
if o.x == 0.0 && o.y == 0.0 {
past_center = true;
} else if past_center {
after.push(o);
} else {
before.push(o);
}
}
(before, after)
}
/// Instances covering `region` (document space) as plain nodes for a pattern painted with /// Instances covering `region` (document space) as plain nodes for a pattern painted with
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort /// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort
/// Pattern Fills; Expand uses [`Self::instances_in_with`]. /// Pattern Fills; Expand uses [`Self::instances_in_with`].
@@ -744,6 +776,20 @@ mod tests {
} }
} }
#[test]
fn preview_paint_order_respects_overlap() {
let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
def.copies = 3;
assert!(close(*def.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(-10.0, -10.0)));
let mut d2 = def.clone();
d2.overlap = Overlap { right_in_front: true, bottom_in_front: true };
assert!(close(*d2.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(10.0, 10.0)));
assert_eq!(def.preview_offsets_in_paint_order().len(), 9);
let (before, after) = def.preview_offsets_dimmed_sides();
assert_eq!(before.len() + after.len(), 8);
assert!(before.iter().all(|o| *o != Vec2::ZERO) && after.iter().all(|o| *o != Vec2::ZERO));
}
#[test] #[test]
fn covering_offsets_and_overlap_order() { fn covering_offsets_and_overlap_order() {
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]); let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
+59 -10
View File
@@ -1,4 +1,5 @@
//! Placed documents: another VectorCraft file placed linked ([`PlacedDocument`]). //! Placed documents: another VectorCraft file, or a page of a PDF or `.ai` file, placed linked
//! ([`PlacedDocument`]).
//! //!
//! The object keeps the link to the file and the file's bytes, stored like image data in //! The object keeps the link to the file and the file's bytes, stored like image data in
//! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the //! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the
@@ -6,8 +7,9 @@
//! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting //! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting
//! expand it like a live object ([`crate::live::expanded`]). //! expand it like a live object ([`crate::live::expanded`]).
//! //!
//! The file is read by the native format's loader, which lives above this crate and registers //! The file is read by the engine's loader (native documents, PDF and `.ai` pages), which lives
//! itself ([`set_loader`]); the art read is kept in a small cache by content. Before exporting, //! above this crate and registers itself ([`set_loader`]); the art read is kept in a small cache
//! by content. Before exporting,
//! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses //! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses
//! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy //! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy
//! of the document, so they never meet the user's or show in panels. Placed documents inside a //! of the document, so they never meet the user's or show in panels. Placed documents inside a
@@ -25,16 +27,21 @@ use vectorcraft_color::Paint;
use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol}; use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol};
/// A placed document: an artboard of another VectorCraft file, shown as one locked object. /// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` file,
/// shown as one locked object.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlacedDocument { pub struct PlacedDocument {
/// The file (its `page` is the artboard shown, 1-based). /// The file (its `page` is the artboard or page shown, 1-based).
pub link: LinkInfo, pub link: LinkInfo,
/// Key into [`Document::images`]: the file's bytes (see [`key_for`]). /// Key into [`Document::images`]: the file's bytes (see [`key_for`]).
pub key: String, pub key: String,
/// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`). /// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`).
#[serde(default, skip_serializing_if = "crate::skip::is_default")] #[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub bounding: bool, pub bounding: bool,
/// The box of a PDF or `.ai` page shown when not `bounding` (File › Place's `crop`); a
/// VectorCraft document shows its artboard whatever this says.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub page_box: PageBox,
/// The artboard's size (or the art's) at 100% (pt). /// The artboard's size (or the art's) at 100% (pt).
pub width: f64, pub width: f64,
pub height: f64, pub height: f64,
@@ -45,11 +52,43 @@ pub struct PlacedDocument {
pub placement: PlacementOptions, pub placement: PlacementOptions,
} }
/// The box of a PDF page a placed PDF or `.ai` file shows (File › Place's `crop`, other than
/// `bounding`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PageBox {
/// The visible page area.
#[default]
Crop,
Art,
Trim,
Bleed,
Media,
}
impl PageBox {
/// The `crop` param value.
pub fn id(self) -> &'static str {
match self {
PageBox::Crop => "crop",
PageBox::Art => "art",
PageBox::Trim => "trim",
PageBox::Bleed => "bleed",
PageBox::Media => "media",
}
}
}
/// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing /// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing
/// artboard `page` (`bounding`: its art's bounds): objects showing the same thing share a blob and /// artboard or page `page` (`bounding`: its art's bounds; else the page's box `page_box`):
/// its preview. /// objects showing the same thing share a blob and its preview.
pub fn key_for(hash: &str, page: u32, bounding: bool) -> String { pub fn key_for(hash: &str, page: u32, bounding: bool, page_box: PageBox) -> String {
format!("{hash}-{page}{}", if bounding { "b" } else { "" }) let shown = match (bounding, page_box) {
(true, _) => "b",
(false, PageBox::Crop) => "",
(false, b) => b.id(),
};
format!("{hash}-{page}{shown}")
} }
impl PlacedDocument { impl PlacedDocument {
@@ -576,6 +615,7 @@ mod tests {
link: LinkInfo::new("/art/logo.vectorcraft"), link: LinkInfo::new("/art/logo.vectorcraft"),
key: key.into(), key: key.into(),
bounding: false, bounding: false,
page_box: PageBox::Crop,
width: 80.0, width: 80.0,
height: 40.0, height: 40.0,
xf: Affine::translate((10.0, 10.0)), xf: Affine::translate((10.0, 10.0)),
@@ -616,9 +656,18 @@ mod tests {
let v = serde_json::to_value(&n).unwrap(); let v = serde_json::to_value(&n).unwrap();
assert_eq!(v["kind"]["type"], "placeddocument"); assert_eq!(v["kind"]["type"], "placeddocument");
assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft"); assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft");
// The box of a PDF page is written only when it isn't the crop box.
assert!(v["kind"].get("page_box").is_none(), "{v}");
let back: Node = serde_json::from_value(v).unwrap(); let back: Node = serde_json::from_value(v).unwrap();
assert_eq!(back, n); assert_eq!(back, n);
assert_eq!(key_for("img00", 2, true), "img00-2b"); assert_eq!(key_for("img00", 2, true, PageBox::Crop), "img00-2b");
assert_eq!(key_for("img00", 2, false, PageBox::Crop), "img00-2");
assert_eq!(key_for("img00", 2, false, PageBox::Trim), "img00-2trim");
let mut trim = placed("doc1-1trim");
trim.page_box = PageBox::Trim;
let v = serde_json::to_value(&trim).unwrap();
assert_eq!(v["page_box"], "trim");
assert_eq!(serde_json::from_value::<PlacedDocument>(v).unwrap(), trim);
} }
#[test] #[test]
+1
View File
@@ -154,6 +154,7 @@ pub const LANGUAGES: &[(&str, Quotes)] = &[
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }), ("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), ("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }), ("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }), ("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }), ("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
]; ];
+1 -1
View File
@@ -189,7 +189,7 @@ fn fx(id: &str, params: Value) -> Effect {
/// A gradient of `(offset, colour, opacity)` stops. /// A gradient of `(offset, colour, opacity)` stops.
fn gradient(kind: GradientKind, stops: &[(f32, &str, f32)]) -> Gradient { fn gradient(kind: GradientKind, stops: &[(f32, &str, f32)]) -> Gradient {
Gradient { kind, stops: stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect() } Gradient::new(kind, stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect())
} }
/// A linear gradient fitted to each object's box at `angle` degrees (90: upwards). /// A linear gradient fitted to each object's box at `angle` degrees (90: upwards).
+40 -1
View File
@@ -104,6 +104,12 @@ pub struct CharStyle {
/// their line line up with it. /// their line line up with it.
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
pub char_align: CharAlign, pub char_align: CharAlign,
/// Character Direction (Character panel menu, Middle Eastern options): these characters are
/// set left to right or right to left inside their paragraph, each stretch of them a Unicode
/// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives
/// them.
#[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")]
pub char_direction: Option<ParaDirection>,
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take /// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
/// its proportional heights, `vpal`. /// its proportional heights, `vpal`.
@@ -297,6 +303,7 @@ impl Default for CharStyle {
stroke_align: StrokeAlign::Center, stroke_align: StrokeAlign::Center,
position: CharPosition::Normal, position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline, char_align: CharAlign::RomanBaseline,
char_direction: None,
proportional_metrics: false, proportional_metrics: false,
tate_chu_yoko: false, tate_chu_yoko: false,
small_caps: None, small_caps: None,
@@ -862,7 +869,8 @@ impl From<ParaStyleFile> for ParaStyle {
} }
} }
/// A paragraph's base direction ([`ParaStyle::direction`]). /// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set
/// in ([`CharStyle::char_direction`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum ParaDirection { pub enum ParaDirection {
@@ -1702,6 +1710,21 @@ impl TextObject {
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
} }
} }
/// How far the characters' strokes paint past their glyphs, in document space: they scale
/// with the type.
pub fn char_stroke_outset(&self) -> f64 {
let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0);
self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k
}
/// How far the characters' strokes can paint past their glyphs, their miter spikes and square
/// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must
/// redraw. They stretch with the type, so this is as far as its transform takes them on either
/// axis. The visual bounds take [`Self::char_stroke_outset`].
pub fn char_stroke_reach(&self) -> f64 {
let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max);
let [a, b, c, d, ..] = self.xf.as_coeffs();
reach * (a.abs() + c.abs()).max(b.abs() + d.abs())
}
pub fn transform(&mut self, a: Affine) { pub fn transform(&mut self, a: Affine) {
self.xf = a * self.xf; self.xf = a * self.xf;
} }
@@ -1906,6 +1929,22 @@ impl crate::Document {
mod tests { mod tests {
use super::*; use super::*;
/// Character Direction is saved only when set, and styles written before it existed read as
/// Default.
#[test]
fn character_direction_is_saved_only_when_set() {
let st = CharStyle::default();
assert_eq!(st.char_direction, None);
assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none());
for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] {
let set = CharStyle { char_direction: Some(d), ..CharStyle::default() };
assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
}
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().char_direction, None);
}
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. /// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
#[test] #[test]
fn ruby_is_saved_and_missing_ruby_reads_as_none() { fn ruby_is_saved_and_missing_ruby_reads_as_none() {
+18 -2
View File
@@ -565,7 +565,7 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
} }
None => { None => {
let effects = st.effects.clone(); let effects = st.effects.clone();
let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.clone()) }; let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.stroke_as_fill()) };
piece(outline, FillRule::NonZero, AppearanceItem::Fill(fill)) piece(outline, FillRule::NonZero, AppearanceItem::Fill(fill))
} }
} }
@@ -673,6 +673,22 @@ mod tests {
assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]); assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.dropShadow"]);
} }
#[test]
fn a_baked_stroke_leaves_dither_behind() {
// Strokes ignore dither, so the filled outline goes without it and looks as the stroke did.
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
let mut g = vectorcraft_color::GradientPaint::new(Default::default());
g.gradient.dither = true;
s.paint = Paint::Gradient(Box::new(g));
}
let m = bake_appearance(&n).unwrap();
let ch = m.children().unwrap();
let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") };
let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") };
assert!(!g.gradient.dither);
}
#[test] #[test]
fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() { fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
@@ -1034,7 +1050,7 @@ mod tests {
fn gradient_copies(id: u64) -> Node { fn gradient_copies(id: u64) -> Node {
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)); let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0));
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2}))); n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2})));
n n
+2 -2
View File
@@ -589,7 +589,7 @@ mod tests {
} }
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
// The type's characters fit the gradient to its layout; the square has it placed across itself. // The type's characters fit the gradient to its layout; the square has it placed across itself.
let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() }; let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() };
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style); let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style);
@@ -615,7 +615,7 @@ mod tests {
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))]; let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))];
for (k, paint) in paints.into_iter().enumerate() { for (k, paint) in paints.into_iter().enumerate() {
+15
View File
@@ -598,6 +598,21 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", "{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.",
json!({"detail": 4.0, "smoothness": 4.0}), json!({"detail": 4.0, "smoothness": 4.0}),
), ),
r(
"sketch.conteCrayon",
"Conté Crayon…",
SKETCH,
"{foregroundLevel: 0..20 (12; how far the black crayon reaches from the shadows into the midtones), backgroundLevel: 0..20 (10; how far the white crayon reaches from the highlights into the midtones), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; the paper's surface, made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} redraws the object in a dense black crayon in its dark areas and a pure white crayon in its light areas, on mid-grey paper whose texture breaks the strokes and stands in relief, keeping its coverage. Public help names Foreground Level, Background Level and the texture options but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.",
json!({
"foregroundLevel": 12.0,
"backgroundLevel": 10.0,
"texture": "canvas",
"scaling": 100.0,
"relief": 4.0,
"lightDirection": "top",
"invert": false
}),
),
r( r(
"sketch.graphicPen", "sketch.graphicPen",
"Graphic Pen…", "Graphic Pen…",
+2 -2
View File
@@ -71,7 +71,7 @@ pub(crate) fn shade(c: Color, rgb: [f64; 3]) -> Color {
pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint { pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint {
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect(); let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect();
let mut p = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); let mut p = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None }); p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(p)) Paint::Gradient(Box::new(p))
} }
@@ -334,7 +334,7 @@ mod tests {
stop.opacity = 0.4; stop.opacity = 0.4;
stop.midpoint = 0.3; stop.midpoint = 0.3;
let g = GradientPaint { let g = GradientPaint {
gradient: Gradient { kind: GradientKind::Linear, stops: vec![stop, GradientStop::new(1.0, Color::BLACK)] }, gradient: Gradient::new(GradientKind::Linear, vec![stop, GradientStop::new(1.0, Color::BLACK)]),
geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }), geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }),
angle: 0.0, angle: 0.0,
swatch: None, swatch: None,
+23 -3
View File
@@ -2,8 +2,8 @@
//! Poster Edges, Rough Pastels, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes, //! Poster Edges, Rough Pastels, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes,
//! Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes and Sumi-e, Distort › Diffuse //! Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes and Sumi-e, Distort › Diffuse
//! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, //! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize,
//! Sharpen › Unsharp Mask, Sketch › Bas Relief, Chalk & Charcoal, Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, //! Sharpen › Unsharp Mask, Sketch › Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Halftone Pattern,
//! Reticulation, Stamp and Torn Edges, Stylize › //! Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges and Water Paper, Stylize ›
//! Glowing Edges, Texture //! Glowing Edges, Texture
//! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video › //! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video ›
//! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels. //! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
@@ -44,7 +44,7 @@ pub use sketch::{HALFTONE_TYPES, HalftonePattern};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture}; pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]). /// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 55] = [ pub const PIXEL_EFFECTS: [&str; 56] = [
"artistic.coloredPencil", "artistic.coloredPencil",
"artistic.cutout", "artistic.cutout",
"artistic.dryBrush", "artistic.dryBrush",
@@ -82,6 +82,7 @@ pub const PIXEL_EFFECTS: [&str; 55] = [
"sketch.chalkCharcoal", "sketch.chalkCharcoal",
"sketch.charcoal", "sketch.charcoal",
"sketch.chrome", "sketch.chrome",
"sketch.conteCrayon",
"sketch.graphicPen", "sketch.graphicPen",
"sketch.halftonePattern", "sketch.halftonePattern",
"sketch.notePaper", "sketch.notePaper",
@@ -210,6 +211,11 @@ pub enum PixelFx {
ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 }, ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 },
/// Sketch › Chrome: polished chrome relief with `detail` (0..10) and `smoothness` (0..10). /// Sketch › Chrome: polished chrome relief with `detail` (0..10) and `smoothness` (0..10).
Chrome { detail: f64, smoothness: f64 }, Chrome { detail: f64, smoothness: f64 },
/// Sketch › Conté Crayon: dark areas in a black crayon, light areas in a white one, on mid-grey
/// paper; `foreground` and `background` (0..20) set how far each crayon reaches into the
/// midtones, and the paper is a `texture` surface scaled by `scaling` (0.5..2), in relief as
/// strong as `relief` (0..50), lit from `light`, its heights turned over when `invert`.
ConteCrayon { foreground: f64, background: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
/// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long /// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long
/// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink. /// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink.
GraphicPen { length: f64, balance: f64, direction: StrokeDirection }, GraphicPen { length: f64, balance: f64, direction: StrokeDirection },
@@ -483,6 +489,15 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0), pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0),
}, },
"sketch.chrome" => PixelFx::Chrome { detail: num(p, "detail", 4.0).clamp(0.0, 10.0), smoothness: num(p, "smoothness", 4.0).clamp(0.0, 10.0) }, "sketch.chrome" => PixelFx::Chrome { detail: num(p, "detail", 4.0).clamp(0.0, 10.0), smoothness: num(p, "smoothness", 4.0).clamp(0.0, 10.0) },
"sketch.conteCrayon" => PixelFx::ConteCrayon {
foreground: num(p, "foregroundLevel", 12.0).clamp(0.0, 20.0),
background: num(p, "backgroundLevel", 10.0).clamp(0.0, 20.0),
texture: Texture::parse(text(p, "texture", "canvas")),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
light: Light::parse(text(p, "lightDirection", "top")),
invert: flag(p, "invert", false),
},
"sketch.graphicPen" => PixelFx::GraphicPen { "sketch.graphicPen" => PixelFx::GraphicPen {
length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0), length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0),
balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0), balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0),
@@ -617,6 +632,7 @@ impl PixelFx {
PixelFx::UnsharpMask { .. } PixelFx::UnsharpMask { .. }
| PixelFx::ChalkCharcoal { .. } | PixelFx::ChalkCharcoal { .. }
| PixelFx::Chrome { .. } | PixelFx::Chrome { .. }
| PixelFx::ConteCrayon { .. }
| PixelFx::HalftonePattern { .. } | PixelFx::HalftonePattern { .. }
| PixelFx::NotePaper { .. } | PixelFx::NotePaper { .. }
| PixelFx::Plaster { .. } | PixelFx::Plaster { .. }
@@ -692,6 +708,7 @@ impl PixelFx {
PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)), PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)),
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH), PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)), PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)),
PixelFx::ConteCrayon { .. } => Some(sketch::CONTE_REACH),
PixelFx::FilmGrain { .. } PixelFx::FilmGrain { .. }
| PixelFx::Mezzotint { .. } | PixelFx::Mezzotint { .. }
| PixelFx::HalftonePattern { .. } | PixelFx::HalftonePattern { .. }
@@ -778,6 +795,9 @@ impl PixelFx {
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold), PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure), PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure),
PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness), PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness),
PixelFx::ConteCrayon { foreground, background, ref texture, scaling, relief, light, invert } => {
sketch::conte_crayon(px, w, h, space, foreground, background, texture, scaling, relief, light, invert)
}
PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction), PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction),
PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind), PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind),
PixelFx::NotePaper { balance, graininess, relief } => sketch::note_paper(px, w, h, space, balance, graininess, relief), PixelFx::NotePaper { balance, graininess, relief } => sketch::note_paper(px, w, h, space, balance, graininess, relief),
+81 -5
View File
@@ -1,9 +1,12 @@
//! Effect › Sketch (Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Halftone Pattern, Note Paper, Photocopy, Plaster, //! Effect › Sketch (Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Halftone Pattern, Note Paper, Photocopy,
//! Reticulation, Stamp, Torn Edges and Water Paper; Graphic Pen lives in //! Plaster, Reticulation, Stamp, Torn Edges and Water Paper; Graphic Pen lives in
//! [`super::brushstrokes`]). Original filters built from public behaviour documentation: //! [`super::brushstrokes`]). Original filters built from public behaviour documentation:
//! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in //! <https://helpx.adobe.com/illustrator/using/summary-effects.html> (Sketch effects redraw in
//! black and white; Water Paper flows colours on damp fibrous paper) and the public Photoshop Elements Artistic filters guide. Visual //! black and white; Water Paper flows colours on damp fibrous paper; Conté Crayon draws dark areas
//! approximations, not proprietary reproductions. //! in black and light areas in white) and the public Photoshop Elements Sketch filters guide
//! (<https://helpx.adobe.com/photoshop-elements/using/sketch-filters.html>: Conté Crayon's
//! foreground and background levels and texture options). Visual approximations, not proprietary
//! reproductions.
use std::f64::consts::{FRAC_1_SQRT_2, TAU}; use std::f64::consts::{FRAC_1_SQRT_2, TAU};
@@ -12,11 +15,12 @@ use vectorcraft_geom::{Point, Vec2};
use super::distort::displace; use super::distort::displace;
use super::edges::sobel; use super::edges::sobel;
use super::pixelate::{Place, cell, rand, value_noise}; use super::pixelate::{Place, cell, rand, value_noise};
use super::texture::{Light, Tones, luma, premultiply, smooth_noise, straight}; use super::texture::{Light, Texture, Tones, luma, premultiply, smooth_noise, straight, texturizer};
use super::{PixelSpace, blur_plane, pick}; use super::{PixelSpace, blur_plane, pick};
const SALT_CHALK: u64 = 216; const SALT_CHALK: u64 = 216;
const SALT_CHROME: u64 = 0xC40_4D45; const SALT_CHROME: u64 = 0xC40_4D45;
const SALT_CONTE: u64 = 0xC047E;
const SALT_TORN: u64 = 0x746f726e5f6564; const SALT_TORN: u64 = 0x746f726e5f6564;
const SALT_WATER: u64 = 208; const SALT_WATER: u64 = 208;
const EDGE_SOFT: f64 = 0.8; const EDGE_SOFT: f64 = 0.8;
@@ -362,6 +366,78 @@ pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel
}); });
} }
/// How far (document units) Conté Crayon samples the softened tones that choose its crayons.
pub(super) const CONTE_REACH: f64 = 3.0 * TONE_SOFT + 1.0;
/// How much of a crayon is laid down where `amount` (0..1: how dark, for the dark crayon, or how
/// light, for the light one) meets its `level` (0..1) and `grain` (0..1, the paper's tooth and the
/// stroke): at level 0 only the deepest tones take the crayon, at level 1 it reaches past the
/// middle tone; the grain breaks its edge.
fn crayon(amount: f64, level: f64, grain: f64) -> f64 {
ramp((amount - (0.9 - 0.45 * level) + 0.4 * (grain - 0.5)) / 0.12 + 0.5)
}
/// Conté Crayon: the dark areas drawn in a dense black crayon and the light areas in a pure white
/// one, on mid-grey paper whose tooth (`texture` scaled by `scaling` (0.5..2), its heights turned
/// over when `invert`) and diagonal strokes break the crayons. `foreground` and `background`
/// (0..20) set how far the black and the white crayon reach into the midtones; the paper then
/// stands in relief as strong as `relief` (0..50), lit from `light`, as Texturizer draws it.
/// Coverage is kept.
#[allow(clippy::too_many_arguments)] // the effect's seven options
pub(super) fn conte_crayon(
px: &mut [[u8; 4]],
w: usize,
h: usize,
space: &PixelSpace,
foreground: f64,
background: f64,
texture: &Texture,
scaling: f64,
relief: f64,
light: Light,
invert: bool,
) {
if !([foreground, background, scaling, relief].iter().all(|v| v.is_finite()) && scaling > 0.0) {
return;
}
let Some(place) = Place::new(space, w, h) else { return };
let fg = foreground.clamp(0.0, 20.0) / 20.0;
let bg = background.clamp(0.0, 20.0) / 20.0;
let tones = Tones::new(px, w, h, TONE_SOFT / place.px);
// The tooth: how far the surface stands above its neighbours a quarter point (or half a pixel)
// away, so that crayon catches on the peaks and leaves the hollows bare.
let step = (0.25f64).max(0.5 * place.px / scaling);
let flip = if invert { -1.0 } else { 1.0 };
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let t = Point::new(q.x / scaling, q.y / scaling);
let tooth = if t.x.is_finite() && t.y.is_finite() {
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
let ridge = (at(0.0, 0.0) - 0.25 * (at(-step, 0.0) + at(step, 0.0) + at(0.0, -step) + at(0.0, step))) / step;
let tooth = 0.5 + 0.5 * (2.0 * flip * ridge).tanh();
if tooth.is_finite() { tooth } else { 0.5 }
} else {
0.5
};
// Strokes along the two diagonals, about a point across and several long.
let (u, v) = ((q.x + q.y) * FRAC_1_SQRT_2, (q.x - q.y) * FRAC_1_SQRT_2);
let dark_stroke = value_noise(SALT_CONTE, u * 1.2, v * 0.15);
let light_stroke = value_noise(SALT_CONTE + 1, v * 1.2, u * 0.15);
let tone = tones.get(x, y);
let black = crayon(1.0 - tone, fg, 0.55 * tooth + 0.45 * dark_stroke);
let white = crayon(tone, bg, 0.55 * tooth + 0.45 * light_stroke);
let paper = 0.4 + 0.2 * tone;
let grey = ((paper + (1.0 - paper) * white) * (1.0 - black)) as f32;
*p = premultiply([grey; 3], f32::from(p[3]) / 255.0);
});
if relief > 0.0 {
texturizer(px, w, h, space, texture, scaling, relief, light, invert);
}
}
/// Torn Edges: the object in high-contrast black and white with ragged edges. `image_balance` /// Torn Edges: the object in high-contrast black and white with ragged edges. `image_balance`
/// (0..50) shifts whether midtones fall to ink or paper; `smoothness` (0..15) softens tones and /// (0..50) shifts whether midtones fall to ink or paper; `smoothness` (0..15) softens tones and
/// the fraying; `contrast` (0..20) hardens the split. Coverage is kept. /// the fraying; `contrast` (0..20) hardens the split. Coverage is kept.
+2
View File
@@ -1224,6 +1224,8 @@ fn texture_params_take_defaults_and_stay_in_range() {
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()), ("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
("artistic.roughPastels", "texture", TEXTURES.as_slice()), ("artistic.roughPastels", "texture", TEXTURES.as_slice()),
("artistic.roughPastels", "lightDirection", LIGHT_DIRECTIONS.as_slice()), ("artistic.roughPastels", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
("sketch.conteCrayon", "texture", TEXTURES.as_slice()),
("sketch.conteCrayon", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
] { ] {
let doc = crate::effect_info(id).unwrap().params; let doc = crate::effect_info(id).unwrap().params;
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect(); let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
+116 -2
View File
@@ -1,4 +1,4 @@
//! Sketch › Bas Relief, Charcoal, Photocopy and Stamp. //! Sketch › Bas Relief, Charcoal, Conté Crayon, Photocopy and Stamp.
use serde_json::json; use serde_json::json;
use vectorcraft_doc::Effect; use vectorcraft_doc::Effect;
@@ -173,9 +173,123 @@ fn bas_relief_lights_the_surfaces_facing_the_light() {
#[test] #[test]
fn sketch_filters_reach_beyond_nothing_they_do_not_paint() { fn sketch_filters_reach_beyond_nothing_they_do_not_paint() {
for id in ["sketch.basRelief", "sketch.charcoal", "sketch.photocopy", "sketch.stamp"] { for id in ["sketch.basRelief", "sketch.charcoal", "sketch.conteCrayon", "sketch.photocopy", "sketch.stamp"] {
let f = fx(id, json!({})); let f = fx(id, json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage"); assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage");
assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach"); assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach");
} }
} }
/// The mean of channel 0 over columns `x0..x1` of every row.
fn mean(d: &[u8], w: usize, h: usize, x0: usize, x1: usize) -> f64 {
let sum: u32 = (0..h).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| u32::from(at(d, w, x, y))).sum();
f64::from(sum) / ((x1 - x0) * h) as f64
}
/// How many pixels of channel 0 are lighter than 192.
fn white(d: &[u8]) -> usize {
d.as_chunks::<4>().0.iter().filter(|p| p[0] > 192).count()
}
#[test]
fn conte_crayon_params_take_defaults_and_stay_in_range() {
let defaults = PixelFx::ConteCrayon {
foreground: 12.0,
background: 10.0,
texture: Texture::Canvas,
scaling: 1.0,
relief: 4.0,
light: Light::Top,
invert: false,
};
assert_eq!(fx("sketch.conteCrayon", json!({})), defaults);
assert_eq!(
fx(
"sketch.conteCrayon",
json!({"foregroundLevel": -5, "backgroundLevel": 1e308, "texture": "Brick", "scaling": 10, "relief": 99, "lightDirection": "bottomRight", "invert": true})
),
PixelFx::ConteCrayon {
foreground: 0.0,
background: 20.0,
texture: Texture::Brick,
scaling: 0.5,
relief: 50.0,
light: Light::BottomRight,
invert: true
}
);
for junk in [
json!({"foregroundLevel": "NaN", "scaling": "1e999"}),
json!({"backgroundLevel": null, "texture": 3, "relief": [1], "lightDirection": "up", "invert": "yes"}),
] {
assert_eq!(fx("sketch.conteCrayon", junk.clone()), defaults, "{junk}");
}
let doc = crate::effect_info("sketch.conteCrayon").unwrap().params;
assert!(doc.contains("unverified") && doc.contains("foregroundLevel") && doc.contains("backgroundLevel"), "{doc}");
}
/// A grey ramp: its shadows in black crayon, its highlights in white crayon, the midtones on grey
/// paper between them; grey only, coverage kept, the same on every run.
#[test]
fn conte_crayon_draws_the_shadows_black_and_the_highlights_white() {
let (w, h) = (96, 32);
let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8));
let out = run("sketch.conteCrayon", json!({"relief": 0}), &src, w, h);
for p in out.as_chunks::<4>().0 {
assert!(p[0] == p[1] && p[1] == p[2] && p[3] == 255, "{p:?}");
}
let (shadows, mids, lights) = (mean(&out, w, h, 0, 12), mean(&out, w, h, w / 2 - 6, w / 2 + 6), mean(&out, w, h, w - 12, w));
assert!(shadows < 40.0, "shadows {shadows}");
assert!(lights > 215.0, "highlights {lights}");
assert!(shadows + 30.0 < mids && mids + 30.0 < lights, "{shadows} {mids} {lights}");
assert_eq!(out, run("sketch.conteCrayon", json!({"relief": 0}), &src, w, h), "deterministic");
// The relief shades the paper but keeps it grey.
let lit = run("sketch.conteCrayon", json!({}), &src, w, h);
assert_ne!(lit, out);
assert!(lit.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2]));
}
/// Foreground Level spreads the black crayon into the midtones, Background Level the white one.
#[test]
fn conte_crayon_levels_spread_each_crayon_into_the_midtones() {
let (w, h) = (64, 64);
let mid = image(w, h, |_, _| grey(128));
let with = |p: serde_json::Value| run("sketch.conteCrayon", p, &mid, w, h);
let (few, many) = (with(json!({"foregroundLevel": 0, "relief": 0})), with(json!({"foregroundLevel": 20, "relief": 0})));
assert!(black(&many) > black(&few) + w * h / 4, "{} vs {}", black(&many), black(&few));
let (few, many) = (
with(json!({"foregroundLevel": 0, "backgroundLevel": 0, "relief": 0})),
with(json!({"foregroundLevel": 0, "backgroundLevel": 20, "relief": 0})),
);
assert!(white(&many) > white(&few) + w * h / 4, "{} vs {}", white(&many), white(&few));
}
/// The paper's texture, scale and inversion break the crayon differently; the relief is lit from
/// the chosen side.
#[test]
fn conte_crayon_texture_options_change_the_paper() {
let (w, h) = (64, 64);
let mid = image(w, h, |x, y| grey((64 + x + y) as u8));
let with = |p: serde_json::Value| run("sketch.conteCrayon", p, &mid, w, h);
let canvas = with(json!({"relief": 0}));
for other in [
json!({"relief": 0, "texture": "brick"}),
json!({"relief": 0, "texture": "sandstone"}),
json!({"relief": 0, "scaling": 200}),
json!({"relief": 0, "invert": true}),
] {
assert_ne!(with(other.clone()), canvas, "{other}");
}
let top = with(json!({"texture": "brick", "relief": 20, "lightDirection": "top"}));
let bottom = with(json!({"texture": "brick", "relief": 20, "lightDirection": "bottom"}));
assert_ne!(top, bottom);
assert_ne!(top, with(json!({"texture": "brick", "relief": 0, "lightDirection": "top"})));
survives(
"sketch.conteCrayon",
&[
json!({}),
json!({"foregroundLevel": "NaN", "backgroundLevel": 1e308, "scaling": -1e308, "relief": 1e308, "texture": "burlap", "invert": true}),
json!({"foregroundLevel": -1e308, "backgroundLevel": [1], "texture": {}, "lightDirection": 3, "relief": "NaN"}),
],
);
}
+1 -1
View File
@@ -181,7 +181,7 @@ fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> C
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = let stops =
g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect(); g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect();
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None }); paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint)) Paint::Gradient(Box::new(paint))
} }
+2 -2
View File
@@ -15,7 +15,7 @@
use kurbo::{BezPath, Point, Shape, Vec2}; use kurbo::{BezPath, Point, Shape, Vec2};
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint}; use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP, lerp_color}; use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP};
use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer};
use vectorcraft_geom::FillRule; use vectorcraft_geom::FillRule;
@@ -375,7 +375,7 @@ pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines:
/// stroke's outline), coloured as [`gradient_slices`] paint it. /// stroke's outline), coloured as [`gradient_slices`] paint it.
pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> { pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> {
let across = st.gradient_mode == StrokeGradientMode::Across; let across = st.gradient_mode == StrokeGradientMode::Across;
let color = |t: f64| g.sample_with(t.clamp(0.0, 1.0) as f32, lerp_color); let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32);
// Along: a column at each stop (colours are linear between columns), close enough to look smooth. // Along: a column at each stop (colours are linear between columns), close enough to look smooth.
let marks: Vec<f64> = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() }; let marks: Vec<f64> = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() };
let max_dt = match (across, st.profile.is_some()) { let max_dt = match (across, st.profile.is_some()) {
+1 -1
View File
@@ -286,7 +286,7 @@ fn a_masked_surface_under_a_colored_light_stays_whole() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let mut half = GradientStop::new(1.0, RED); let mut half = GradientStop::new(1.0, RED);
half.opacity = 0.5; half.opacity = 0.5;
let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] }); let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half]));
for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] { for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] {
let mut n = stroked_rect(100.0); let mut n = stroked_rect(100.0);
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) {
+8 -4
View File
@@ -372,8 +372,10 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_
} }
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the /// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
/// targeted item (on the first target object), else `default`. /// targeted item (on the first target object), else `default`. A `stroke` other than true, false or
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool) -> Result<bool> { /// null is an error of command `cmd`.
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result<bool> {
check_switches(p, &["stroke"], cmd)?;
if let Some(b) = p.get("stroke").and_then(Value::as_bool) { if let Some(b) = p.get("stroke").and_then(Value::as_bool) {
return Ok(b); return Ok(b);
} }
@@ -486,7 +488,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
if source == target { if source == target {
return Err(bad(C, "`source` and `target` are the same object")); return Err(bad(C, "`source` and `target` are the same object"));
} }
let copy = bool_or(p, "copy", false); let copy = switch_or(p, "copy", false, C)?;
// When both objects come out as they were, the document and its undo history stay as they are. // When both objects come out as they were, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone(); let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| { super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
@@ -549,6 +551,8 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
fn set_item(s: &mut Session, p: &Value) -> Result<Value> { fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = index_param(p, "index", "appearance.setItem")?; let idx = index_param(p, "index", "appearance.setItem")?;
super::paint::check_keep_model(p, "appearance.setItem")?; super::paint::check_keep_model(p, "appearance.setItem")?;
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
check_switches(p, &["visible"], "appearance.setItem")?;
let paint = paint_from(s, p)?; let paint = paint_from(s, p)?;
let ids = appearance_targets(s, p)?; let ids = appearance_targets(s, p)?;
if ids.is_empty() { if ids.is_empty() {
@@ -762,7 +766,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> { fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.copyFrom"; const C: &str = "appearance.copyFrom";
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false)); let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None }; let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
let selected = match ids_param(p, "ids") { let selected = match ids_param(p, "ids") {
+9 -9
View File
@@ -970,7 +970,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc; let d = &s.doc()?.doc;
let name = resolve_symbol(d, p, "symbol.place")?; let name = resolve_symbol(d, p, "symbol.place")?;
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
let at = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y)); let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?);
let size = natural_size(d, &name); let size = natural_size(d, &name);
let reg = d.symbol_registration(&name); let reg = d.symbol_registration(&name);
let parent = s.doc()?.target_parent()?; let parent = s.doc()?.target_parent()?;
@@ -1099,7 +1099,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) {
return Err(bad(C, format!("no symbol named `{name}`"))); return Err(bad(C, format!("no symbol named `{name}`")));
} }
let expand = bool_or(p, "expandInstances", true); let expand = switch_or(p, "expandInstances", true, C)?;
s.edit("Delete Symbol", |d, _| { s.edit("Delete Symbol", |d, _| {
let inst = instances_of(d, Some(&name)); let inst = instances_of(d, Some(&name));
if expand { if expand {
@@ -1216,10 +1216,10 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
let pts = points(p, C)?; let pts = points(p, C)?;
let st = s.doc()?; let st = s.doc()?;
let name = resolve_symbol(&st.doc, p, C)?; let name = resolve_symbol(&st.doc, p, C)?;
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0); let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
let density = f64_or(p, "density", 5.0).clamp(1.0, 10.0); let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0);
let scale = f64_or(p, "scale", 1.0).clamp(0.01, 100.0); let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0);
let alt = bool_or(p, "alt", false); let alt = switch_or(p, "alt", false, C)?;
let size = natural_size(&st.doc, &name); let size = natural_size(&st.doc, &name);
let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set));
// Only a new Symbol Set needs a layer that takes new art. // Only a new Symbol Set needs a layer that takes new art.
@@ -1303,9 +1303,9 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
} }
super::paint::check_keep_model(p, C)?; super::paint::check_keep_model(p, C)?;
let pts = points(p, C)?; let pts = points(p, C)?;
let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0); let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0; let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0;
let alt = bool_or(p, "alt", false); let alt = switch_or(p, "alt", false, C)?;
let st = s.doc()?; let st = s.doc()?;
let color = match p.get("color").and_then(color_value) { let color = match p.get("color").and_then(color_value) {
Some(c) => super::paint::new_color(s, p, c), Some(c) => super::paint::new_color(s, p, c),
+85 -25
View File
@@ -24,6 +24,7 @@ use vectorcraft_tools::builder::{
}; };
use vectorcraft_trace as tr; use vectorcraft_trace as tr;
use super::draw2::replace_with_pieces;
use super::edit::{outermost, selected_roots}; use super::edit::{outermost, selected_roots};
use super::paint::paint_from; use super::paint::paint_from;
use super::pathops::{Beside, shape_node}; use super::pathops::{Beside, shape_node};
@@ -39,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Builder", "Shape Builder",
[], [],
None, None,
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the fill of the front-most object over the first region the drag touches that an object covers; the current fill when only lines enclose every region touched) | \"swatches\" (the current fill), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges} (counts deleted or merged)", "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it goes directly above it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
has_selection_or_ids, has_selection_or_ids,
sb_merge sb_merge
), ),
@@ -248,10 +249,13 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
if pts.is_empty() || !pts.iter().all(|q| finite(*q)) { if pts.is_empty() || !pts.iter().all(|q| finite(*q)) {
return Err(bad(C, "points must be a non-empty list of finite [x,y]")); return Err(bad(C, "points must be a non-empty list of finite [x,y]"));
} }
let erase = bool_or(p, "erase", false); let erase = switch_or(p, "erase", false, C)?;
check_numbers(p, &["tolerance", "gap"], C)?;
// Line pieces and edges are only deleted (erase mode), never merged. // Line pieces and edges are only deleted (erase mode), never merged.
let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
let open_filled_as_closed = bool_or(p, "openFilledAsClosed", true); // In Merge Mode, Clicking Stroke Splits the Path: a click (one point) on a stroke splits it.
let split_tol = (switch_or(p, "splitStroke", false, C)? && !erase && pts.len() == 1).then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0));
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?;
let swatches = match p.get("pickColorFrom") { let swatches = match p.get("pickColorFrom") {
None | Some(Value::Null) => false, None | Some(Value::Null) => false,
Some(v) => match v.as_str() { Some(v) => match v.as_str() {
@@ -260,20 +264,47 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))), _ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))),
}, },
}; };
let current_fill = s.paint.fill.clone();
let fill = match fill_param(s, p, C)? { let fill = match fill_param(s, p, C)? {
None if swatches => Some(current_fill.clone()), None if swatches => Some(s.paint.fill.clone()),
f => f, f => f,
}; };
let roots = root_ids(s, p)?; let roots = root_ids(s, p)?;
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
let (ids, merged, touched) = s.edit(label, |d, sel| { let (ids, merged, touched, split) = s.edit(label, |d, sel| {
let (map, leaves) = BuilderMap::with_gap(d, &roots, erase, open_filled_as_closed, gap_param(p)); let (map, leaves) = BuilderMap::with_gap(d, &roots, erase || split_tol.is_some(), open_filled_as_closed, gap_param(p));
let outlines = b::drawn_outlines(d, &leaves);
let leaves: Vec<Node> = leaves.into_iter().cloned().collect(); let leaves: Vec<Node> = leaves.into_iter().cloned().collect();
if map.shapes.is_empty() { if map.shapes.is_empty() {
return Err(EngineError::Other("Shape Builder: select paths or compound paths".into())); return Err(EngineError::Other("Shape Builder: select paths or compound paths".into()));
} }
let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25); let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25);
if let Some(tol) = split_tol
&& let Some(&q) = pts.first()
&& let Some((i, run)) = map.stroke_at(&outlines, q, tol)
&& let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i))
&& let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn))
&& !rest.is_empty()
{
// The path keeps the rest of it, and a copy of it with the run under the click goes
// directly above it; the other objects stay as they are.
let pieces = [rest, PathData::single(run)].into_iter().filter(|pd| !pd.is_empty()).collect();
let mut replaced = Beside::replacing(d, &[leaf.id]);
let new_ids = replace_with_pieces(d, &mut replaced, leaf.id, pieces)?;
replaced.finish(d)?;
let split = *new_ids.last().ok_or(EngineError::NoNode(leaf.id))?;
// Pick Color From Color Swatches (or `fill`): the stroke takes that paint.
if let Some(f) = &fill
&& let Some(n) = d.node_mut(split)
{
n.appearance.set_stroke(f.clone());
}
// The roots stay selected, with the two paths in place of the path when it was one.
let ids: Vec<NodeId> = roots.iter().flat_map(|r| if *r == leaf.id { new_ids.clone() } else { vec![*r] }).collect();
sel.set(ids.iter().copied());
let mut touched = Touched::default();
touched.add(map.hit(q, Some(tol)));
return Ok((ids, None, touched, Some(split)));
}
let mut touched = Touched::default(); let mut touched = Touched::default();
for q in sample_polyline(&pts, step) { for q in sample_polyline(&pts, step) {
touched.add(map.hit(q, piece_tol)); touched.add(map.hit(q, piece_tol));
@@ -283,16 +314,17 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
} }
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
// Edges come back from the planar map within a few hundredths of the outlines they follow.
let cut_tol = step * 0.2;
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p)); let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
// The merged shape takes the look of the front-most object over the first region, or (for an // Pick Color From Artwork: the merged shape takes the look of the front-most object under the
// area only lines enclose) of the front-most object and the current fill. // drag's first point, else under its last point, else of the front-most object. Color
let covered = picked.iter().find_map(|r| r.top()); // Swatches: the current fill, with the rest of the look of the front-most object over the
// first region the drag touched that an object covers, else of the front-most object.
let top_at = |q: &Point| map.region_at(*q).and_then(|k| map.regions.get(k)).and_then(|(r, ..)| r.top());
let covered =
if swatches { picked.iter().find_map(|r| r.top()) } else { pts.first().and_then(top_at).or_else(|| pts.last().and_then(top_at)) };
let style_src = covered.or(map.shapes.len().checked_sub(1)); let style_src = covered.or(map.shapes.len().checked_sub(1));
let merged_fill = fill.or_else(|| covered.is_none().then_some(current_fill));
let mut new_nodes: Vec<Node> = vec![]; let mut new_nodes: Vec<Node> = vec![];
let mut merged_id = None; let mut merged_id = None;
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
@@ -306,7 +338,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
} else { } else {
for k in keep { for k in keep {
if let Some((l, _)) = map.lines.get(k) { if let Some((l, _)) = map.lines.get(k) {
new_nodes.push(shape_node(d, l.path.clone(), Some(leaf))); new_nodes.push(remnant(d, leaf, l.path.clone(), None));
} }
} }
} }
@@ -330,6 +362,9 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
}; };
let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none(); let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none();
// Edges come back from the planar map within a few hundredths of the outlines they
// follow, whatever else is selected.
let cut_tol = b::outline_snap(&sh.path);
let mut cut = false; let mut cut = false;
for piece in &cuts { for piece in &cuts {
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) { if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
@@ -340,12 +375,13 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
if !in_union && !cut { if !in_union && !cut {
new_nodes.push(d.reid(leaf)); new_nodes.push(d.reid(leaf));
} else if !rest.is_empty() { } else if !rest.is_empty() {
new_nodes.push(shape_node(d, rest, Some(leaf))); // What is left of its own outline keeps its fill rule; united faces fill with non-zero.
new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero)));
} }
} }
if !erase && !union.is_empty() && Some(i) == style_src { if !erase && !union.is_empty() && Some(i) == style_src {
let mut m = shape_node(d, union.clone(), Some(leaf)); let mut m = shape_node(d, union.clone(), Some(leaf));
if let Some(f) = &merged_fill { if let Some(f) = &fill {
m.appearance.set_fill(f.clone()); m.appearance.set_fill(f.clone());
} }
merged_id = Some(m.id); merged_id = Some(m.id);
@@ -354,15 +390,19 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
} }
let new_ids = replace_roots(d, &roots, new_nodes)?; let new_ids = replace_roots(d, &roots, new_nodes)?;
sel.set(new_ids.iter().copied()); sel.set(new_ids.iter().copied());
Ok((new_ids, merged_id, touched)) Ok((new_ids, merged_id, touched, None))
})?; })?;
Ok(json!({ let mut out = json!({
"ids": ids_json(&ids), "ids": ids_json(&ids),
"merged": merged.map(|m| m.0), "merged": merged.map(|m| m.0),
"regions": touched.regions.len(), "regions": touched.regions.len(),
"lines": touched.lines.len(), "lines": touched.lines.len(),
"edges": touched.edges.len(), "edges": touched.edges.len(),
})) });
if let Some(e) = split {
out["split"] = json!(e.0);
}
Ok(out)
} }
/// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids. /// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids.
@@ -381,10 +421,30 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec<Node>) -> Result
Ok(ids) Ok(ids)
} }
/// What is left of path or compound path `src`: a copy of it with a fresh id and the geometry `path`
/// (a path for one subpath, else a compound path, as [`shape_node`] makes them). It keeps the rest
/// of `src`: its look, name, opacity mask and attributes, and its fill rule unless `rule` is given.
fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option<FillRule>) -> Node {
let own = match &src.kind {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
_ => FillRule::NonZero,
};
let shaped = shape_node(d, path, None);
let mut n = src.clone();
n.id = shaped.id;
n.kind = shaped.kind;
if let NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. } = &mut n.kind {
*r = rule.unwrap_or(own);
}
n
}
fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> { fn sb_regions(s: &mut Session, p: &Value) -> Result<Value> {
let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?;
check_numbers(p, &["gap"], "shapeBuilder.regions")?;
let roots = root_ids(s, p)?; let roots = root_ids(s, p)?;
let d = &s.doc()?.doc; let d = &s.doc()?.doc;
let (map, leaves) = BuilderMap::with_gap(d, &roots, true, bool_or(p, "openFilledAsClosed", true), gap_param(p)); let (map, leaves) = BuilderMap::with_gap(d, &roots, true, open_filled_as_closed, gap_param(p));
let id_of = |i: usize| leaves.get(i).map(|n| n.id.0); let id_of = |i: usize| leaves.get(i).map(|n| n.id.0);
let regions: Vec<Value> = map let regions: Vec<Value> = map
.regions .regions
@@ -714,7 +774,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()), Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()),
None => None, None => None,
}; };
let close = bool_or(p, "closeGaps", false); let close = switch_or(p, "closeGaps", false, C)?;
let targets = live_targets(s, p)?; let targets = live_targets(s, p)?;
let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| { let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| {
let (mut faces, mut gaps, mut closed) = (0, vec![], 0); let (mut faces, mut gaps, mut closed) = (0, vec![], 0);
@@ -801,11 +861,11 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?;
super::paint::check_keep_model(p, C)?; super::paint::check_keep_model(p, C)?;
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone());
let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0)); let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?;
if !(0.0..=1000.0).contains(&width) { if !(0.0..=1000.0).contains(&width) {
return Err(bad(C, "width out of range")); return Err(bad(C, "width out of range"));
} }
let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0); let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0);
let edge = s.edit("Live Paint Bucket", |d, _| { let edge = s.edit("Live Paint Bucket", |d, _| {
let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?;
let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?;
@@ -863,7 +923,7 @@ fn view_param(cmd: &str, p: &Value) -> Result<Option<TraceView>> {
fn trace_params(p: &Value) -> Result<tr::TraceParams> { fn trace_params(p: &Value) -> Result<tr::TraceParams> {
let name = str_param(p, "preset").unwrap_or("Default"); let name = str_param(p, "preset").unwrap_or("Default");
let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false); let enhanced = switch_or(p, "enhanced", false, "imageTrace.make")?;
// (A preset without an enhanced version is traced as it is.) // (A preset without an enhanced version is traced as it is.)
let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name)); let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name));
let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?; let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?;
@@ -949,7 +1009,7 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha
fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint { fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; 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 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 }); let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None }); paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(paint)) Paint::Gradient(Box::new(paint))
} }
+74 -10
View File
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
), ),
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line), cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral), cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc), cmd!(
"shape.arc",
"Arc",
[],
None,
"{x1, y1, x2, y2, closed?, slope?} or {x, y, lengthX, lengthY, reference?, baseAlong?: x|y, slope?, closed?, fillArc?} → {id}",
has_doc,
arc
),
cmd!( cmd!(
"shape.rectangularGrid", "shape.rectangularGrid",
"Rectangular Grid", "Rectangular Grid",
@@ -207,6 +215,7 @@ fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result<Affine> {
} }
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> { fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["radius"], "shape.rectangle")?;
let r = rect_of(p, "shape.rectangle")?; let r = rect_of(p, "shape.rectangle")?;
let radius = f64_or(p, "radius", 0.0).max(0.0); let radius = f64_or(p, "radius", 0.0).max(0.0);
let live = LiveShape::Rectangle { let live = LiveShape::Rectangle {
@@ -230,6 +239,7 @@ fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
fn polygon(s: &mut Session, p: &Value) -> Result<Value> { fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?); let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?);
let r = f64_req(p, "radius", "shape.polygon")?.abs(); let r = f64_req(p, "radius", "shape.polygon")?.abs();
check_numbers(p, &["sides", "rotation"], "shape.polygon")?;
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32; let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
let rot = f64_or(p, "rotation", 0.0); let rot = f64_or(p, "rotation", 0.0);
let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians()); let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians());
@@ -240,6 +250,7 @@ fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
fn star(s: &mut Session, p: &Value) -> Result<Value> { fn star(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?); let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?);
let r1 = f64_req(p, "radius1", "shape.star")?.abs(); let r1 = f64_req(p, "radius1", "shape.star")?.abs();
check_numbers(p, &["radius2", "points", "rotation"], "shape.star")?;
let r2 = f64_or(p, "radius2", r1 / 2.0).abs(); let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32; let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32;
let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0)); let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0));
@@ -251,6 +262,24 @@ fn star(s: &mut Session, p: &Value) -> Result<Value> {
fn flare(s: &mut Session, p: &Value) -> Result<Value> { fn flare(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_color::{BlendMode, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_color::{BlendMode, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
let c = Point::new(f64_req(p, "cx", "shape.flare")?, f64_req(p, "cy", "shape.flare")?); let c = Point::new(f64_req(p, "cx", "shape.flare")?, f64_req(p, "cy", "shape.flare")?);
let numbers = [
"diameter",
"opacity",
"brightness",
"growth",
"fuzziness",
"rays",
"longest",
"rayFuzziness",
"rings",
"largest",
"x2",
"y2",
"direction",
"pathLength",
"seed",
];
check_numbers(p, &numbers, "shape.flare")?;
let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0); let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0);
let r = (f64_or(p, "diameter", 100.0) / 2.0).max(0.5); let r = (f64_or(p, "diameter", 100.0) / 2.0).max(0.5);
let opacity = pct("opacity", 50.0).min(1.0) as f32; let opacity = pct("opacity", 50.0).min(1.0) as f32;
@@ -279,7 +308,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
}; };
let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) }; let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) };
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| { let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops }); let mut g = GradientPaint::new(Gradient::new(GradientKind::Radial, stops));
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0, focal: None }); g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0, focal: None });
Paint::Gradient(Box::new(g)) Paint::Gradient(Box::new(g))
}; };
@@ -360,20 +389,46 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
fn spiral(s: &mut Session, p: &Value) -> Result<Value> { fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?); let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?);
check_numbers(p, &["segments"], "shape.spiral")?;
let path = shapes::spiral( let path = shapes::spiral(
c, c,
f64_req(p, "radius", "shape.spiral")?, f64_req(p, "radius", "shape.spiral")?,
f64_or(p, "decay", 80.0), number_or(p, "decay", 80.0, "shape.spiral")?,
p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32, p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32,
bool_or(p, "clockwise", true), switch_or(p, "clockwise", true, "shape.spiral")?,
); );
add_art(s, "Spiral", path_kind(path, None), None) add_art(s, "Spiral", path_kind(path, None), None)
} }
fn arc(s: &mut Session, p: &Value) -> Result<Value> { fn arc(s: &mut Session, p: &Value) -> Result<Value> {
let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?); let closed = switch_or(p, "closed", false, "shape.arc")?;
let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?); let fill_arc = switch_or(p, "fillArc", false, "shape.arc")?;
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None) let base_x = !matches!(p.get("baseAlong").and_then(Value::as_str), Some("y" | "Y"));
let path = if p.get("lengthX").is_some() || p.get("lengthY").is_some() {
let anchor = Point::new(f64_req(p, "x", "shape.arc")?, f64_req(p, "y", "shape.arc")?);
let reference = p.get("reference").and_then(Value::as_u64).unwrap_or(0) as usize;
shapes::arc_segment(
anchor,
reference,
number_or(p, "lengthX", 100.0, "shape.arc")?,
number_or(p, "lengthY", 100.0, "shape.arc")?,
base_x,
number_or(p, "slope", 50.0, "shape.arc")?,
closed,
)
} else {
let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?);
let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?);
let slope = number_or(p, "slope", 100.0, "shape.arc")?;
shapes::arc(a, b, slope, closed, base_x)
};
let fill = if fill_arc { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
let look = s.new_art_look(
fill,
if s.paint.stroke.is_none() { vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK) } else { s.paint.stroke.clone() },
s.paint.stroke_width.max(0.1),
);
add_look(s, "Arc", path_kind(path, None), look, None)
} }
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the /// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
@@ -406,9 +461,11 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) ->
Ok(json!({ "id": id.0 })) Ok(json!({ "id": id.0 }))
} }
/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to /// A grid's divider count `count` (an error unless it is a number or null; 5 unless it is a JSON
/// ±[`shapes::GRID_SKEW_MAX`]). /// integer of 0 or more, so 4.0 and 2.5 give 5; at most 999) with its skew `key` in percent (0 when
/// absent, clamped to ±[`shapes::GRID_SKEW_MAX`]).
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> { fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
check_numbers(p, &[count], cmd)?;
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32; let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let skew = match p.get(key) { let skew = match p.get(key) {
None | Some(Value::Null) => 0.0, None | Some(Value::Null) => 0.0,
@@ -466,6 +523,8 @@ pub(crate) fn anchor_from_json(v: &Value, cmd: &str) -> Result<Anchor> {
} }
fn path_create(s: &mut Session, p: &Value) -> Result<Value> { fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
// Checked with `d` too, where the path data alone decides whether the path is closed.
let closed = switch_or(p, "closed", false, "path.create")?;
let path = if let Some(d) = str_param(p, "d") { let path = if let Some(d) = str_param(p, "d") {
let bp = vectorcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?; let bp = vectorcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?;
PathData::from_bezpath(&bp) PathData::from_bezpath(&bp)
@@ -480,7 +539,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
if anchors.is_empty() { if anchors.is_empty() {
return Err(bad("path.create", "need at least one anchor")); return Err(bad("path.create", "need at least one anchor"));
} }
PathData::single(SubPath::new(anchors, bool_or(p, "closed", false))) PathData::single(SubPath::new(anchors, closed))
}; };
let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
// Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set. // Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set.
@@ -495,6 +554,11 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let x = f64_req(p, "x", "text.create")?; let x = f64_req(p, "x", "text.create")?;
let y = f64_req(p, "y", "text.create")?; let y = f64_req(p, "y", "text.create")?;
super::paint::check_keep_model(p, "text.create")?; super::paint::check_keep_model(p, "text.create")?;
check_numbers(p, &["size"], "text.create")?;
check_switches(p, &["vertical", "placeholder"], "text.create")?;
if let Some(a) = p.get("area") {
check_numbers(a, &["width", "height"], "text.create")?;
}
let text = str_param(p, "text").unwrap_or(""); let text = str_param(p, "text").unwrap_or("");
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false); t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
+12 -11
View File
@@ -665,9 +665,10 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let pressure = stroke_pressure(&samples); let pressure = stroke_pressure(&samples);
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect(); let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0); let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
let closed = bool_or(p, "closed", false); let closed = switch_or(p, "closed", false, C)?;
let brush = str_param(p, "style") == Some("brush"); let brush = str_param(p, "style") == Some("brush");
let keep_selected = bool_or(p, "keepSelected", true); let keep_selected = switch_or(p, "keepSelected", true, C)?;
let filled = switch_or(p, "fill", false, C)?;
if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) {
let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?;
let at_start = str_param(ext, "end") == Some("start"); let at_start = str_param(ext, "end") == Some("start");
@@ -764,7 +765,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "need at least two points")); return Err(bad(C, "need at least two points"));
} }
let sp = fit_freehand(&pts, tol, closed); let sp = fit_freehand(&pts, tol, closed);
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None }; let fill = if filled { s.paint.fill.clone() } else { Paint::None };
// A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing // A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing
// gets black. A brush is its stroke. // gets black. A brush is its stroke.
let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() }; let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() };
@@ -780,12 +781,11 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
fn curvature(s: &mut Session, p: &Value) -> Result<Value> { fn curvature(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.curvature"; const C: &str = "path.curvature";
let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?; let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?;
let pts: Vec<(Point, bool)> = let pts: Vec<(Point, bool)> = arr.iter().map(|v| Ok((point_entry(v, C)?, switch_or(v, "corner", false, C)?))).collect::<Result<_>>()?;
arr.iter().map(|v| Ok((point_entry(v, C)?, v.get("corner").and_then(Value::as_bool).unwrap_or(false)))).collect::<Result<_>>()?;
if pts.is_empty() { if pts.is_empty() {
return Err(bad(C, "need at least one point")); return Err(bad(C, "need at least one point"));
} }
let sp = vectorcraft_tools::draw2::curvature_spline(&pts, bool_or(p, "closed", false)); let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?);
if let Some(id) = id_param(p, "id") { if let Some(id) = id_param(p, "id") {
s.edit("Curvature", |d, sel| { s.edit("Curvature", |d, sel| {
*path_mut(d, id)? = PathData::single(sp); *path_mut(d, id)? = PathData::single(sp);
@@ -831,7 +831,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result<Value> {
None => EndCurve::Keep, None => EndCurve::Keep,
Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?, Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?,
}; };
let corner = bool_or(p, "corner", false); let corner = switch_or(p, "corner", false, C)?;
let op = match str_param(p, "op") { let op = match str_param(p, "op") {
Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?), Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?),
Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at), Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at),
@@ -1030,7 +1030,7 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) { if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) {
return Err(bad(C, "invalid delta")); return Err(bad(C, "invalid delta"));
} }
let perpendicular = bool_or(p, "perpendicular", false); let perpendicular = switch_or(p, "perpendicular", false, C)?;
s.edit("Reshape", |d, _| { s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?; let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
@@ -1490,11 +1490,12 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
} else { } else {
Paint::solid(Color::BLACK) Paint::solid(Color::BLACK)
}; };
let keep_selected = bool_or(p, "keepSelected", false); let keep_selected = switch_or(p, "keepSelected", false, C)?;
let only_selection = bool_or(p, "mergeOnlySelection", false); let only_selection = switch_or(p, "mergeOnlySelection", false, C)?;
let merge = switch_or(p, "merge", true, C)?;
let st = s.doc()?; let st = s.doc()?;
let mut merge_ids = vec![]; let mut merge_ids = vec![];
if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) { if merge && !(only_selection && st.selection.is_empty()) {
let all = Selection::default(); let all = Selection::default();
let from = if only_selection { &st.selection } else { &all }; let from = if only_selection { &st.selection } else { &all };
for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) { for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) {
+56 -3
View File
@@ -11,6 +11,7 @@ use super::clipboard::SwatchChoices;
use super::*; use super::*;
use crate::{Clipboard, EngineError, inspect}; use crate::{Clipboard, EngineError, inspect};
/// Register editing commands, including document-to-document artwork drops and their parameter contracts.
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
cmd!( cmd!(
@@ -73,6 +74,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc, has_doc,
global_rulers global_rulers
), ),
cmd!(
"edit.dropSelection",
"Drop Artwork",
[],
None,
"{sourceDocument: uid, ids: [id…], grab: [x,y], at: [x,y]} copy 1–10000 editable source objects from an open document to the active document, preserving the grab point and clipboard, as one undo step",
has_doc,
drop_selection
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!( cmd!(
@@ -571,6 +581,40 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
ok() ok()
} }
/// Document ids scope node ids: two tabs may contain objects with the same node id.
fn drop_selection(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.dropSelection";
let key = "sourceDocument";
let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?;
let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates")));
let grab = point("grab")?;
let at = point("at")?;
let source = s.documents().iter().find(|d| d.uid == uid).ok_or_else(|| bad(CMD, "that document is no longer open"))?;
let key = "ids";
let values = p[key].as_array().ok_or_else(|| bad(CMD, format!("{key} must be an array of non-negative integer object ids")))?;
if values.is_empty() || values.len() > 10000 {
return Err(bad(CMD, "ids must contain 1–10000 object ids"));
}
let ids = values
.iter()
.map(|value| value.as_u64().map(NodeId).ok_or_else(|| bad(CMD, format!("invalid object id {value}: expected a non-negative integer"))))
.collect::<Result<Vec<_>>>()?;
// Validate the entire batch in one walk; node ids are local to the source document.
let paths = source.doc.index_paths(ids.iter().copied());
if let Some(id) = ids.iter().find(|id| !paths.contains_key(id)) {
return Err(EngineError::NoNode(*id));
}
let clip = Clipboard::copy(source, &roots_of(&source.doc, ids));
let bounds = clip.bounds().ok_or_else(|| bad(CMD, "the art has no bounds"))?;
let center = bounds.center() + Vec2::new(at[0] - grab[0], at[1] - grab[1]);
if !center.x.is_finite() || !center.y.is_finite() {
return Err(bad(CMD, "result would exceed the canvas (coordinates out of range)"));
}
// A drop preserves the source colours even when the flyer has same-named swatches.
let choices = SwatchChoices::parse(PasteMode::Drag.label(), &json!({"swatchConflict": "add"}))?;
paste_clip(s, &json!({"center": [center.x, center.y]}), PasteMode::Drag, &clip, &choices)
}
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
enum PasteMode { enum PasteMode {
Offset, Offset,
@@ -580,9 +624,11 @@ enum PasteMode {
AllArtboards, AllArtboards,
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres. /// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
Library, Library,
Drag,
} }
impl PasteMode { impl PasteMode {
/// Return the user-visible action label for this insertion mode.
fn label(self) -> &'static str { fn label(self) -> &'static str {
match self { match self {
PasteMode::Front => "Paste in Front", PasteMode::Front => "Paste in Front",
@@ -591,6 +637,7 @@ impl PasteMode {
PasteMode::AllArtboards => "Paste on All Artboards", PasteMode::AllArtboards => "Paste on All Artboards",
PasteMode::Offset => "Paste", PasteMode::Offset => "Paste",
PasteMode::Library => "Place from Library", PasteMode::Library => "Place from Library",
PasteMode::Drag => "Drop Artwork",
} }
} }
} }
@@ -623,6 +670,7 @@ fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine {
} }
} }
/// Insert clipboard resources atomically in the active editable context using the requested placement mode.
fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> { fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result<Value> {
let off = s.prefs.paste_offset; let off = s.prefs.paste_offset;
let st = s.doc()?; let st = s.doc()?;
@@ -640,7 +688,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
.map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO })); .map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO }));
// Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art // Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art
// always goes back into its layers. // always goes back into its layers.
let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer)); // A drop goes into the current editable insertion context, not a same-named source layer.
let remember = mode != PasteMode::Drag
&& (st.doc.paste_remembers_layers || boards.is_some())
&& parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
// Front/back: relative to the selection (top-most / bottom-most selected object); with // Front/back: relative to the selection (top-most / bottom-most selected object); with
// nothing selected, the top / bottom of the current layer. // nothing selected, the top / bottom of the current layer.
let anchor = match mode { let anchor = match mode {
@@ -653,7 +704,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
}; };
let placements: Vec<Affine> = match (mode, boards) { let placements: Vec<Affine> = match (mode, boards) {
(_, Some((_, dv))) => vec![Affine::translate(dv)], (_, Some((_, dv))) => vec![Affine::translate(dv)],
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") { (PasteMode::Offset | PasteMode::Library | PasteMode::Drag, None) => vec![match point_param(p, "center") {
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())), Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))), None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
}], }],
@@ -680,7 +731,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
let mut c = d.reid(n); let mut c = d.reid(n);
imported.apply(&mut c); imported.apply(&mut c);
if xf != Affine::IDENTITY { if xf != Affine::IDENTITY {
c.transform(xf, false); // A cross-document drag carries the graphic's appearance with it,
// including the pattern phase, independently of transform-tool settings.
c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() });
} }
let layer = match clip.source_layers.get(k) { let layer = match clip.source_layers.get(k) {
Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))), Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))),
+1 -1
View File
@@ -1151,7 +1151,7 @@ mod tests {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
let (mut s, id) = session_with_rect(); let (mut s, id) = session_with_rect();
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
s.edit("gradient", |d, _| { s.edit("gradient", |d, _| {
let n = d.node_mut(id).unwrap(); let n = d.node_mut(id).unwrap();
n.appearance.set_fill(ramp); n.appearance.set_fill(ramp);
+9 -4
View File
@@ -12,7 +12,7 @@ use vectorcraft_color::cms::Model;
use vectorcraft_color::freeform::spread_scale; use vectorcraft_color::freeform::spread_scale;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
use vectorcraft_doc::live::{GradientMesh, lerp_color}; use vectorcraft_doc::live::GradientMesh;
use vectorcraft_doc::pattern::{PatternDef, place_art}; use vectorcraft_doc::pattern::{PatternDef, place_art};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes}; use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
@@ -736,7 +736,7 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
}); });
} }
let geom = g.resolve(bounds); let geom = g.resolve(bounds);
Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color)) Box::new(move |p| g.gradient.sample_keep_model(geom.param_at(g.gradient.kind, p) as f32))
} }
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every /// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
@@ -745,8 +745,13 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
/// are over `max_gap` apart. The colour is linear in display RGB between neighbours, as the /// are over `max_gap` apart. The colour is linear in display RGB between neighbours, as the
/// canvas draws it. /// canvas draws it.
fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32))> { fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32))> {
let at = |t: f64| (t, g.sample_with(t as f32, lerp_color)); let at = |t: f64| (t, g.sample_keep_model(t as f32));
let mut v = vec![at(t0)]; let mut v = vec![at(t0)];
// A Perceptual gradient curves between its stops: the mesh takes a column at each of the stops
// its expansion adds, so the linear mix between columns follows that curve.
if !g.method.is_classic() {
v.extend(g.expanded().map(|(o, ..)| f64::from(o)).filter(|o| *o > t0 && *o < t1).map(at));
}
for (i, s) in g.stops.iter().enumerate() { for (i, s) in g.stops.iter().enumerate() {
let o = s.offset as f64; let o = s.offset as f64;
if o > t0 && o < t1 { if o > t0 && o < t1 {
@@ -844,7 +849,7 @@ impl Frame {
/// is just its band, so nothing doubles up. /// is just its band, so nothing doubles up.
fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> {
let step = (self.t1 - self.t0) / n as f64; let step = (self.t1 - self.t0) / n as f64;
let color = |k: usize| g.sample_with((self.t0 + (k as f64 + 0.5) * step) as f32, lerp_color); let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32);
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
if self.radial { if self.radial {
let ellipse = |t: f64| { let ellipse = |t: f64| {
File diff suppressed because it is too large. Load diff
+24 -7
View File
@@ -19,8 +19,15 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?; let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
let expanded = expand_presets(s, "document.serialize", p)?; let expanded = expand_presets(s, "document.serialize", p)?;
let p = with_black_output(s, &expanded); let p = with_black_output(s, &expanded);
let doc = &*source(s, f, &p, "document.serialize")?; let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?;
let enc = encode_all(doc, f.id, &p)?; let doc = &*doc;
let mut q = p.clone();
if f.id == "pdf"
&& let Some(o) = q.as_object_mut()
{
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
let enc = encode_all(doc, f.id, &q)?;
let files = enc.named(doc, &default_name(doc, f.extensions[0])); let files = enc.named(doc, &default_name(doc, f.extensions[0]));
let (main, linked) = files.split_at(enc.files.len()); let (main, linked) = files.split_at(enc.files.len());
// Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes. // Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes.
@@ -53,8 +60,15 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.export", str_param(p, "format"), path)?; let f = writable("document.export", str_param(p, "format"), path)?;
let expanded = expand_presets(s, "document.export", p)?; let expanded = expand_presets(s, "document.export", p)?;
let p = with_black_output(s, &expanded); let p = with_black_output(s, &expanded);
let doc = &*source(s, f, &p, "document.export")?; let (doc, display_doc_title) = source(s, f, &p, "document.export")?;
let enc = encode_all(doc, f.id, &p)?; let doc = &*doc;
let mut q = p.clone();
if f.id == "pdf"
&& let Some(o) = q.as_object_mut()
{
o.insert("_displayDocTitle".into(), json!(display_doc_title));
}
let enc = encode_all(doc, f.id, &q)?;
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
} }
@@ -154,13 +168,16 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Va
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text /// The document an export of `f` writes: the active one (see [`export_source`]), or for text
/// with `selectionOnly` the selected objects alone. /// with `selectionOnly` the selected objects alone.
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<Cow<'a, Document>> { fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> {
if f.id == "txt" && bool_or(p, "selectionOnly", false) { if f.id == "txt" && bool_or(p, "selectionOnly", false) {
return Ok(Cow::Owned(selection(s, cmd)?.0)); let doc = selection(s, cmd)?.0;
let display_doc_title = super::has_own_title(&doc);
return Ok((Cow::Owned(doc), display_doc_title));
} }
let st = s.doc()?; let st = s.doc()?;
let doc = export_source(st, p)?; let doc = export_source(st, p)?;
Ok(super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned)) let display_doc_title = super::has_own_title(&doc);
Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title))
} }
/// The selected objects (not on template layers) alone on one layer, with one artboard: their /// The selected objects (not on template layers) alone on one layer, with one artboard: their
+4 -4
View File
@@ -370,7 +370,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]). /// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> { pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
let update_links = s.prefs.update_links == "automatically"; let update_links = s.prefs.update_links == "automatically";
open_read(s, read_for_open(name, bytes, path, p, update_links)?) open_read(s, read_for_open(name, bytes, path, p, update_links, s.prefs.low_res_proxy_eps)?)
} }
/// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]). /// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]).
@@ -396,10 +396,10 @@ impl ReadFile {
/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from, /// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from,
/// and modified ones are read again when `update_links` (Preferences › File Handling › Update /// and modified ones are read again when `update_links` (Preferences › File Handling › Update
/// Links set to Automatically; see [`crate::cmd::links::resolve`]). /// Links set to Automatically; see [`crate::cmd::links::resolve`]).
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool) -> Result<ReadFile> { pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
let opts = LoadOptions::from_params("document.open", p)?; let opts = LoadOptions::from_params("document.open", p)?;
let mut loaded = load_with(name, bytes, &opts)?; let mut loaded = load_with(name, bytes, &opts)?;
let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links); let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps);
Session::refresh_text_bounds(&mut loaded.doc); Session::refresh_text_bounds(&mut loaded.doc);
Ok(ReadFile { loaded, opts, path, links }) Ok(ReadFile { loaded, opts, path, links })
} }
@@ -419,7 +419,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first", "only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
)); ));
} }
let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically"); let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true) open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true)
} }
+16 -2
View File
@@ -793,6 +793,17 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS))) .chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
} }
/// Open-dialog filters for Swatch Libraries and Graphic Style Libraries Other Library… and Locate
/// Library…. The library formats come first so Linux file portals (which grey out files that don't
/// match the default filter) still list `.ase` and the other library extensions; then the same
/// filters as [`open_filters`] so a document can be picked for its swatches or graphic styles.
pub fn library_open_filters(
library_label: &'static str,
library_exts: &'static [&'static str],
) -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once((library_label, library_exts)).chain(open_filters())
}
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text, /// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
/// RTF and Word. /// RTF and Word.
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> { pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
@@ -860,9 +871,12 @@ pub fn file_stem(name: &str) -> String {
/// document that only has the Untitled-N name New gave it is titled after its file, as its tab is /// document that only has the Untitled-N name New gave it is titled after its file, as its tab is
/// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep /// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep
/// the stored title. /// the stored title.
pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool {
!doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> { pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option<vectorcraft_doc::Document> {
let untitled = doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?;
let path = path.filter(|_| untitled && !matches!(format, "vectorcraft" | "template" | "ai"))?;
let mut d = doc.clone(); let mut d = doc.clone();
d.title = file_stem(path); d.title = file_stem(path);
Some(d) Some(d)
+16 -2
View File
@@ -243,7 +243,14 @@ pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() }; let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?; let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
let now = vectorcraft_doc::metadata::output_time(); let now = vectorcraft_doc::metadata::output_time();
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() }) Ok(PdfOptions {
settings: settings(cmd, p)?,
artboards,
display_doc_title: bool_or(p, "_displayDocTitle", super::has_own_title(doc)),
created: doc.metadata.created.or(now),
modified: now,
..Default::default()
})
} }
/// Why a PDF of some artboards carries no editing data. /// Why a PDF of some artboards carries no editing data.
@@ -387,8 +394,15 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let expanded = expand_preset(s, C, p)?; let expanded = expand_preset(s, C, p)?;
let p = &*expanded; let p = &*expanded;
let st = s.doc()?; let st = s.doc()?;
let display_doc_title = super::has_own_title(&st.doc);
let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf"); let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf");
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), p)?; let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("_displayDocTitle".into(), json!(display_doc_title));
} else {
q = json!({ "_displayDocTitle": display_doc_title });
}
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?;
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
} }
+4 -2
View File
@@ -800,10 +800,12 @@ fn items(
}; };
if o.full_document { if o.full_document {
// The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF // The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF
// row writes every artboard of the document itself). // row writes every artboard of the document itself). It is named after the document: an
// SVG of one artboard takes the artboard's name as its title (#3544), and this one stands
// for the whole document.
let (whole, region) = if crop { let (whole, region) = if crop {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
(Arc::new(single_artboard(doc, bounds, "Document")), bounds) (Arc::new(single_artboard(doc, bounds, &doc.title)), bounds)
} else { } else {
(doc.clone(), Rect::ZERO) (doc.clone(), Rect::ZERO)
}; };
+3 -2
View File
@@ -179,9 +179,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
let doc = flat.as_ref().unwrap_or(doc); let doc = flat.as_ref().unwrap_or(doc);
let mut enc = Encoded { warnings, ..Encoded::default() }; let mut enc = Encoded { warnings, ..Encoded::default() };
for artboard in boards { for artboard in boards {
// A chosen artboard's file holds the art over it (#550). // A chosen artboard's file holds the art over it (#550) and is named after it (#3544).
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect)); let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &ExportOptions { artboard, ..opts.clone() }, native.as_deref()); let file = ExportOptions { artboard, artboard_title: chosen, ..opts.clone() };
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &file, native.as_deref());
let bytes = out.take_bytes(); let bytes = out.take_bytes();
for l in out.linked { for l in out.linked {
if !enc.linked.iter().any(|e| e.name == l.name) { if !enc.linked.iter().any(|e| e.name == l.name) {
+8
View File
@@ -384,6 +384,14 @@ fn open_exts_cover_every_readable_format() {
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins"); assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
} }
#[test]
fn library_open_filters_list_library_formats_first() {
let filters: Vec<_> = library_open_filters("Swatch libraries", super::swatchlib::LIBRARY_EXTS).collect();
assert_eq!(filters[0], ("Swatch libraries", super::swatchlib::LIBRARY_EXTS));
assert!(filters[0].1.contains(&"ase"));
assert_eq!(filters[1], ("All readable files", OPEN_EXTS));
}
/// The extensions usage texts list for export: one per writable format, each picking that format /// The extensions usage texts list for export: one per writable format, each picking that format
/// (`.png` picks PNG; PNG-8 is asked for by `format`). /// (`.png` picks PNG; PNG-8 is asked for by `format`).
#[test] #[test]
+18
View File
@@ -125,6 +125,24 @@ fn placed_images_come_back_as_linked_files() {
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
} }
/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no
/// linked file, and `warnings` holds the note raster exports give, once, without naming either
/// image's key.
#[test]
fn images_that_cant_be_decoded_give_the_raster_note_once() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let mut s = session(200.0, 100.0, 1);
crate::tests_menucmds::place_undecodable_png(&mut s);
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
assert_eq!(keys.len(), 2);
let r = s.execute("document.export", &json!({"format": "dxf"})).unwrap();
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{w:?}");
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
assert!(entities(&text(&r)).is_empty() && r.get("linked").is_none(), "{r}");
}
#[test] #[test]
fn bad_dxf_options_are_refused() { fn bad_dxf_options_are_refused() {
let mut s = two_boards(); let mut s = two_boards();
+17
View File
@@ -156,6 +156,23 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() {
assert!(l3.contains("/FlateDecode filter >> image")); assert!(l3.contains("/FlateDecode filter >> image"));
} }
/// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings`
/// holds the note raster exports give, once, without naming either image's key.
#[test]
fn images_that_cant_be_decoded_give_the_raster_note_once() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let mut s = session(200.0, 100.0, 1);
crate::tests_menucmds::place_undecodable_png(&mut s);
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
assert_eq!(keys.len(), 2);
let r = s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap();
let w = warnings(&r);
assert_eq!(w.iter().filter(|w| *w == NOTE).count(), 1, "{w:?}");
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}");
assert!(!postscript(&b64(&r)).contains("filter >> image"), "no image is written");
}
#[test] #[test]
fn bad_eps_options_are_refused_and_formats_list_eps() { fn bad_eps_options_are_refused_and_formats_list_eps() {
let mut s = session(100.0, 100.0, 1); let mut s = session(100.0, 100.0, 1);
@@ -95,6 +95,24 @@ fn raster_effects_are_written_as_images() {
assert!(kinds.contains(&114), "the shadow is an image with transparency"); assert!(kinds.contains(&114), "the shadow is an image with transparency");
} }
/// Two placed images whose pixel data can't be decoded are left out of an EMF or WMF, and
/// `warnings` holds the note raster exports give, once, without naming either image's key.
#[test]
fn images_that_cant_be_decoded_give_the_raster_note_once() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let mut s = session(200.0, 100.0, 1);
crate::tests_menucmds::place_undecodable_png(&mut s);
crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16);
let keys: Vec<String> = s.doc().unwrap().doc.images.keys().cloned().collect();
assert_eq!(keys.len(), 2);
for format in ["emf", "wmf"] {
let r = s.execute("document.export", &json!({"format": format})).unwrap();
let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{format}: {w:?}");
assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{format}: {w:?}");
}
}
#[test] #[test]
fn metafiles_open_and_place() { fn metafiles_open_and_place() {
let mut s = two_boards(); let mut s = two_boards();
+26
View File
@@ -131,6 +131,32 @@ fn options_not_applied_yet_and_document_features_warn() {
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty()); assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
} }
/// A placed 16-bit PNG is written at 8 bits a sample in PDF 1.3 and 1.4 files (PDF/X-3 and the
/// pages of a .ai file too), and keeps 16 bits from PDF 1.5.
#[test]
fn a_16_bit_png_is_written_at_8_bits_before_pdf_1_5() {
let mut s = session(1);
let deep = image::ImageBuffer::from_fn(8, 8, |x, y| image::Rgb([x as u16 * 8000, y as u16 * 8000, 40000]));
let mut png = vec![];
image::DynamicImage::ImageRgb16(deep).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.execute("file.place", &json!({"name": "deep.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [50, 40]})).unwrap();
let readable = json!({"compressText": false});
for (cmd, p, depth) in [
("document.exportPdf", json!({"compatibility": "1.3", "compression": readable}), 8),
("document.exportPdf", json!({"compatibility": "1.4", "compression": readable}), 8),
("document.exportPdf", json!({"standard": "pdfX3", "compression": readable}), 8),
("file.saveCopy", json!({"format": "ai", "compatibility": "1.4", "compress": false}), 8),
("document.exportPdf", json!({"compatibility": "1.5", "compression": readable}), 16),
("document.exportPdf", json!({"compression": readable}), 16),
] {
let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
let (eight, sixteen) = (text.contains("/BitsPerComponent 8"), text.contains("/BitsPerComponent 16"));
assert_eq!((eight, sixteen), (depth == 8, depth == 16), "{cmd} {p}");
assert!(warnings(&v).iter().all(|w| !w.contains("decoded")), "{cmd} {p}: {}", v["warnings"]);
}
}
/// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes, /// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes,
/// and the rest of the page is written. Each reply that carries the PDF's warnings has the note /// and the rest of the page is written. Each reply that carries the PDF's warnings has the note
/// raster exports give, once. /// raster exports give, once.
@@ -89,3 +89,51 @@ fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}"); assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
} }
} }
/// The ink amounts (0..1) `doc` shows at `p` in its images (the topmost image there).
fn image_inks_at(doc: &vectorcraft_doc::Document, p: vectorcraft_geom::Point) -> Option<[f32; 4]> {
fn visit(n: &vectorcraft_doc::Node, out: &mut Vec<vectorcraft_doc::ImageObject>) {
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
out.push((**im).clone());
}
for c in n.children().into_iter().flatten() {
visit(c, out);
}
}
let mut images = vec![];
for l in &doc.layers {
visit(l, &mut images);
}
images.iter().rev().find_map(|im| {
let q = im.xf.inverse() * p;
if q.x < 0.0 || q.y < 0.0 || q.x >= im.width as f64 || q.y >= im.height as f64 {
return None;
}
let ink = doc.images.get(&im.key)?.sample_cmyk_object(q.x, q.y, (im.width, im.height), 1)?;
Some(ink.map(|v| v as f32 / 255.0))
})
}
#[test]
fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() {
// #2160: a CMYK panel under a PNG with see-through pixels. The flattener rasterizes where the
// image reaches; the panel there keeps its ink numbers instead of going through RGB.
let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner());
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "colorMode": "cmyk"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 180, "height": 80})).unwrap();
let teal = [1.0, 0.06, 0.41, 0.03];
s.execute("paint.setFill", &json!({"color": {"c": teal[0], "m": teal[1], "y": teal[2], "k": teal[3]}})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let mut png = vec![];
image::RgbaImage::from_fn(40, 40, |x, _| image::Rgba([0, 120, 255, if x < 20 { 0 } else { 255 }]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
s.execute("file.place", &json!({"name": "logos.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [100, 50]})).unwrap();
let v = s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).unwrap();
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let back = vectorcraft_pdf::import(&bytes).unwrap();
// Behind the image's see-through half: the panel's own inks, K included.
let ink = image_inks_at(&back, vectorcraft_geom::Point::new(88.0, 50.0)).expect("a CMYK image behind the see-through pixels");
assert!(ink.iter().zip(teal).all(|(a, b)| (a - b).abs() <= 1.5 / 255.0), "{ink:?} is not {teal:?}");
}
@@ -108,3 +108,39 @@ fn a_chosen_artboard_s_svg_keeps_an_object_whose_shadow_reaches_it() {
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap(); s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard"); assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard");
} }
/// #3544: an SVG of one artboard on its own (an `artboard` or `range`, Export for Screens, an asset)
/// has the artboard's (or asset's) name as `<title>`; Save's SVG and Export for Screens' Full
/// Document keep the document's.
#[test]
fn an_svg_of_one_artboard_is_titled_after_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"name": "Icons", "width": 24, "height": 24, "artboards": 2})).unwrap();
for (index, name) in [(0, "arrow-left"), (1, "arrow-right")] {
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
}
let square = s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 16, "height": 16})).unwrap()["id"].clone();
let title = |svg: &str| svg.lines().find_map(|l| l.trim().strip_prefix("<title>")?.strip_suffix("</title>")).map(str::to_string);
let serialize = |s: &mut Session, p: serde_json::Value| s.execute("document.serialize", &p).unwrap()["text"].as_str().unwrap().to_string();
assert_eq!(title(&serialize(&mut s, json!({"format": "svg", "artboard": 1}))).as_deref(), Some("arrow-right"));
assert_eq!(title(&serialize(&mut s, json!({"format": "svg", "artboard": 0}))).as_deref(), Some("arrow-left"));
assert_eq!(title(&serialize(&mut s, json!({"format": "svg"}))).as_deref(), Some("Icons"), "Save's SVG");
let all = s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "all"})).unwrap();
let titles: Vec<_> = all["files"].as_array().unwrap().iter().map(|f| title(f["text"].as_str().unwrap())).collect();
assert_eq!(titles, [Some("arrow-left".into()), Some("arrow-right".into())]);
// Export for Screens: each artboard, an asset, and the Full Document.
let screens = |s: &mut Session, p: serde_json::Value| -> Vec<Option<String>> {
let r = s.execute("document.exportForScreens", &p).unwrap();
r["files"]
.as_array()
.unwrap()
.iter()
.map(|f| title(&String::from_utf8(vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap()).unwrap()))
.collect()
};
let svg = json!([{"format": "svg"}]);
assert_eq!(screens(&mut s, json!({"formats": svg})), [Some("arrow-left".into()), Some("arrow-right".into())]);
let asset = s.execute("assets.add", &json!({"ids": [square], "name": "Square"})).unwrap()["assets"][0].clone();
assert_eq!(screens(&mut s, json!({"assets": [asset], "formats": svg})), [Some("Square".into())]);
assert_eq!(screens(&mut s, json!({"fullDocument": true, "formats": svg})), [Some("Icons".into())]);
}
+28 -10
View File
@@ -11,7 +11,8 @@
//! effects reach there is no single colour: those regions are rendered into one image per group, //! effects reach there is no single colour: those regions are rendered into one image per group,
//! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the //! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the
//! regions it overlaps). The raster/vector balance rasterizes whole groups that split into more //! regions it overlaps). The raster/vector balance rasterizes whole groups that split into more
//! regions than it allows (all of them at 0). //! regions than it allows (all of them at 0). For files without transparency (PDF/X-1a, PDF/X-3)
//! a CMYK document's images are opaque CMYK: its colours keep their inks there too.
//! //!
//! Edit → Transparency Flattener Presets: the built-in presets plus the user's, saved with the //! Edit → Transparency Flattener Presets: the built-in presets plus the user's, saved with the
//! preferences ([`crate::Prefs::flattener_presets`]); a `preset` named anywhere options are taken //! preferences ([`crate::Prefs::flattener_presets`]); a `preset` named anywhere options are taken
@@ -28,6 +29,7 @@ use serde_json::{Value, json};
use vectorcraft_color::blend::{cmyk_planes, composite, planes_cmyk}; use vectorcraft_color::blend::{cmyk_planes, composite, planes_cmyk};
use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
use vectorcraft_doc::cmyk::Inks;
use vectorcraft_doc::{Appearance, ColorMode, Document, Effect, ImageBlob, ImageObject, Knockout, Node, NodeId, NodeKind}; use vectorcraft_doc::{Appearance, ColorMode, Document, Effect, ImageBlob, ImageObject, Knockout, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
use vectorcraft_pathops as po; use vectorcraft_pathops as po;
@@ -132,8 +134,8 @@ pub struct FlattenOptions {
pub preserve_overprints: bool, pub preserve_overprints: bool,
/// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with /// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with
/// see-through pixels (these keys, [`see_through_images`]) count as transparency, and /// see-through pixels (these keys, [`see_through_images`]) count as transparency, and
/// rasterized areas clipped to their regions are opaque. `None` for ordinary flattening. Not a /// rasterized areas clipped to their regions are opaque (in a CMYK document, images of ink
/// preset option. /// amounts). `None` for ordinary flattening. Not a preset option.
#[serde(skip)] #[serde(skip)]
pub no_transparency: Option<BTreeSet<String>>, pub no_transparency: Option<BTreeSet<String>>,
} }
@@ -805,7 +807,7 @@ struct RegionFill {
/// A rendered image of complex regions, clipped to them or not. /// A rendered image of complex regions, clipped to them or not.
struct Raster { struct Raster {
png: Vec<u8>, blob: ImageBlob,
width: u32, width: u32,
height: u32, height: u32,
xf: Affine, xf: Affine,
@@ -945,7 +947,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
/// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out /// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out
/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and /// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and
/// its `clip` reach a pixel further. Fails when the image can't be encoded. /// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink
/// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs.
/// Fails when the image can't be encoded.
fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Result<Raster> { fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Result<Raster> {
let mut scale = ppi / 72.0; let mut scale = ppi / 72.0;
scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt());
@@ -958,11 +962,26 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
rect.x0 + (rect.width() * scale).ceil().max(1.0) / scale, rect.x0 + (rect.width() * scale).ceil().max(1.0) / scale,
rect.y0 + (rect.height() * scale).ceil().max(1.0) / scale, rect.y0 + (rect.height() * scale).ceil().max(1.0) / scale,
); );
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let tmp = isolated_doc(doc, art.to_vec());
let mut r = vectorcraft_render::Renderer::new(); let mut r = vectorcraft_render::Renderer::new();
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha); let no_alpha = o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha;
if no_alpha && doc.color_mode == ColorMode::Cmyk {
// Drawn as inks over the paper: as screen colours, the file would separate them again,
// and a CMYK colour comes back with other inks (K moves into C, M and Y).
let opts = vectorcraft_render::RenderOptions { background: Some([255; 4]), skip_templates: true, ..Default::default() };
let inks = r.render_region_inks(&tmp, region, scale, &opts);
vectorcraft_doc::cancel::check()?;
let (width, height) = vectorcraft_render::region_pixels(region, scale);
// Past the size of a CMYK image: drawn in screen colours, below.
if let Some(blob) = Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)) {
return Ok(Raster { blob, width, height, xf, clip });
}
}
let mut img = r.render_region(&tmp, region, scale, !o.preserve_alpha);
// The drawing stops early when cancelled: this image is not whole. // The drawing stops early when cancelled: this image is not whole.
vectorcraft_doc::cancel::check()?; vectorcraft_doc::cancel::check()?;
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha { if no_alpha {
// Without transparency the clip alone shapes the image: it is opaque, over white (its // Without transparency the clip alone shapes the image: it is opaque, over white (its
// edge pixels too). // edge pixels too).
for p in img.pixels.as_chunks_mut::<4>().0 { for p in img.pixels.as_chunks_mut::<4>().0 {
@@ -1002,9 +1021,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
} }
} }
} }
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let png = img.to_png().map_err(|e| EngineError::Other(format!("Flatten Transparency: {e}")))?; let png = img.to_png().map_err(|e| EngineError::Other(format!("Flatten Transparency: {e}")))?;
Ok(Raster { png, width: img.width, height: img.height, xf, clip }) Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip })
} }
/// `n` at full opacity with Normal blending throughout (objects, fills and strokes). /// `n` at full opacity with Normal blending throughout (objects, fills and strokes).
@@ -1115,7 +1133,7 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
/// An image of `r` (in a clip group when it is clipped). /// An image of `r` (in a clip group when it is clipped).
fn raster_node(d: &mut Document, r: Raster) -> Node { fn raster_node(d: &mut Document, r: Raster) -> Node {
let key = unique_key(d, "flattened"); let key = unique_key(d, "flattened");
d.images.insert(key.clone(), ImageBlob::new("image/png", r.png)); d.images.insert(key.clone(), r.blob);
let image = Node::new( let image = Node::new(
d.alloc_id(), d.alloc_id(),
NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })), NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })),
+9
View File
@@ -101,6 +101,12 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String {
/// The fonts (family, style, version) of the type in the layers and symbols of `d`. /// The fonts (family, style, version) of the type in the layers and symbols of `d`.
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> { pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
art_fonts(d, &[])
}
/// The fonts (family, style, version) of the type in the layers and symbols of `d` and in `art`
/// (art read from a file, its resources in `d`).
pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet<UsedFont> {
let mut fonts = BTreeSet::new(); let mut fonts = BTreeSet::new();
let mut add = |n: &Node| { let mut add = |n: &Node| {
if let NodeKind::Text(t) = &n.kind { if let NodeKind::Text(t) = &n.kind {
@@ -111,6 +117,9 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
for s in &d.symbols { for s in &d.symbols {
s.art.walk(&mut add); s.art.walk(&mut add);
} }
for n in art {
n.walk(&mut add);
}
fonts fonts
} }
+3 -3
View File
@@ -176,7 +176,7 @@ pub(crate) fn parse_mode(m: &str) -> std::result::Result<FreeformMode, String> {
/// automatic points it shows first, and the stops follow the points' colours. /// automatic points it shows first, and the stops follow the points' colours.
fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result<R, String>) -> Result<R> { fn edit<R>(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result<R, String>) -> Result<R> {
let item = item_target(s, p, cmd)?; let item = item_target(s, p, cmd)?;
let stroke = edits_stroke(s, p, item, !s.fill_active)?; let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?;
let item = item.of_kind(s, !stroke); let item = item.of_kind(s, !stroke);
let ids = item.targets(s, p)?; let ids = item.targets(s, p)?;
if ids.is_empty() { if ids.is_empty() {
@@ -308,7 +308,7 @@ fn split_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.splitLine"; const C: &str = "paint.freeform.splitLine";
let line = index_param(p, "line", C)?; let line = index_param(p, "line", C)?;
let segment = index_param(p, "segment", C)?; let segment = index_param(p, "segment", C)?;
let t = f64_or(p, "t", 0.5); let t = number_or(p, "t", 0.5, C)?;
let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?; let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?;
s.select_freeform_point(Some(index)); s.select_freeform_point(Some(index));
Ok(json!({ "index": index })) Ok(json!({ "index": index }))
@@ -332,7 +332,7 @@ fn select_point(s: &mut Session, p: &Value) -> Result<Value> {
fn get(s: &mut Session, p: &Value) -> Result<Value> { fn get(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.get"; const C: &str = "paint.freeform.get";
let stroke = bool_or(p, "stroke", !s.fill_active); let stroke = switch_or(p, "stroke", !s.fill_active, C)?;
let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?; let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?;
let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None }; let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None };
let mut v = vectorcraft_tools::params::freeform_json(&f); let mut v = vectorcraft_tools::params::freeform_json(&f);
+35 -6
View File
@@ -1,7 +1,7 @@
//! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector. //! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector.
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::appearance::stroke_paint_bounds;
use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode}; use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode};
use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_geom::{Affine, Point, Rect};
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient", "Gradient",
["Window", "Gradient"], ["Window", "Gradient"],
None, None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even, which SVG and PDF exports draw as an image; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
has_doc, has_doc,
edit_gradient edit_gradient
), ),
@@ -105,6 +105,14 @@ fn parse_kind(k: &str) -> Parsed<GradientKind> {
GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)")) GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)"))
} }
/// The `method` param of `p`, if given: how the colours between the stops mix.
fn method_param(p: &Value) -> Parsed<Option<GradientMethod>> {
match p.get("method") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(GradientMethod::parse).map(Some).ok_or_else(|| "`method` must be classic or perceptual".into()),
}
}
/// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d` /// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d`
/// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and /// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and
/// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps /// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps
@@ -117,7 +125,12 @@ pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed<Value> {
if d.swatch(&name).is_none() && st.get("color").is_some() { if d.swatch(&name).is_none() && st.get("color").is_some() {
continue; continue;
} }
let tint = st.get("tint").and_then(Value::as_f64).map(|t| (t / 100.0).clamp(0.0, 1.0) as f32); let tint = st
.get("tint")
.filter(|v| !v.is_null())
.map(|v| v.as_f64().ok_or_else(|| format!("stop {i}: `{k}` must be a number", k = "tint")))
.transpose()?;
let tint = tint.map(|t| (t / 100.0).clamp(0.0, 1.0) as f32);
let Paint::Solid { color, swatch, tint } = swatch_solid(d, &name, tint).map_err(|e| format!("stop {i}: {e}"))? else { let Paint::Solid { color, swatch, tint } = swatch_solid(d, &name, tint).map_err(|e| format!("stop {i}: {e}"))? else {
continue; continue;
}; };
@@ -219,7 +232,12 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed<GradientPaint> {
} }
let kind = str_param(g, "kind").map(parse_kind).transpose()?.unwrap_or_default(); let kind = str_param(g, "kind").map(parse_kind).transpose()?.unwrap_or_default();
let stops = g.get("stops").map(parse_stops).transpose()?.unwrap_or_else(|| Gradient::default().stops); let stops = g.get("stops").map(parse_stops).transpose()?.unwrap_or_else(|| Gradient::default().stops);
let mut gp = GradientPaint::new(Gradient { kind, stops }); let mut gp = GradientPaint::new(Gradient::new(kind, stops));
gp.gradient.dither = match g.get("dither") {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or("`dither` must be true or false")?,
};
gp.gradient.method = method_param(g)?.unwrap_or_default();
gp.angle = f64_or(g, "angle", 0.0); gp.angle = f64_or(g, "angle", 0.0);
gp.swatch = str_param(g, "swatch").map(str::to_string); gp.swatch = str_param(g, "swatch").map(str::to_string);
let geom = GeomEdit::parse(g)?; let geom = GeomEdit::parse(g)?;
@@ -314,6 +332,12 @@ pub(crate) fn apply_gradient_edit_in(
// Midpoints mirrored with their segments. // Midpoints mirrored with their segments.
gp.gradient.reverse(); gp.gradient.reverse();
} }
if let Some(d) = p.get("dither").and_then(Value::as_bool) {
gp.gradient.dither = d;
}
if let Some(m) = method_param(p)? {
gp.gradient.method = m;
}
// Angle and aspect edits keep the focal point in its place in the extent ellipse. // Angle and aspect edits keep the focal point in its place in the extent ellipse.
let before = gp.geom; let before = gp.geom;
if let Some(a) = p.get("angle").and_then(Value::as_f64) { if let Some(a) = p.get("angle").and_then(Value::as_f64) {
@@ -372,7 +396,9 @@ pub(crate) fn apply_gradient_edit_in(
fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> { fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.editGradient"; const C: &str = "paint.editGradient";
let item = item_target(s, p, C)?; let item = item_target(s, p, C)?;
let stroke = edits_stroke(s, p, item, !s.fill_active)?; let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
check_switches(p, &["reverse", "dither"], C)?;
check_numbers(p, &["angle"], C)?;
let item = item.of_kind(s, !stroke); let item = item.of_kind(s, !stroke);
let ids = item.targets(s, p)?; let ids = item.targets(s, p)?;
// How the gradient lies on a stroke (the panel's Stroke buttons). // How the gradient lies on a stroke (the panel's Stroke buttons).
@@ -381,6 +407,9 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")), Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?), Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
}; };
if p.get("dither").and_then(Value::as_bool).is_some() && stroke {
return Err(bad(C, "`dither` applies to gradient fills (stroke: false)"));
}
super::paint::check_keep_model(p, C)?; super::paint::check_keep_model(p, C)?;
// New RGB stop colors take the document's model; those of the swatches the stops name don't. // New RGB stop colors take the document's model; those of the swatches the stops name don't.
let stored = stop_colors(s, p, p); let stored = stop_colors(s, p, p);
@@ -554,7 +583,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
} }
let p = &p; let p = &p;
let item = item_target(s, p, C)?; let item = item_target(s, p, C)?;
let stroke = edits_stroke(s, p, item, !s.fill_active)?; let stroke = edits_stroke(s, p, item, !s.fill_active, C)?;
let item = item.of_kind(s, !stroke); let item = item.of_kind(s, !stroke);
let targets = match item { let targets = match item {
ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?, ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?,
+5
View File
@@ -870,6 +870,11 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
} }
None => vec![p], None => vec![p],
}; };
check_switches(p, &["moveArt", "scaleArt", "strokes", "corners", "patterns", "lockedAndHidden"], "artboard.setProps")?;
for q in &entries {
check_switches(q, &["locked"], "artboard.setProps")?;
check_numbers(q, &["x", "y", "width", "height"], "artboard.setProps")?;
}
// Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move // Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move
// Artwork with Artboard gathers it) and its guides from the old rectangle onto the new one. // Artwork with Artboard gathers it) and its guides from the old rectangle onto the new one.
let scale_art = bool_or(p, "scaleArt", false); let scale_art = bool_or(p, "scaleArt", false);
+92 -30
View File
@@ -17,16 +17,17 @@
//! (`links.embed`) and Unembed writes an embedded image to a file it then links to //! (`links.embed`) and Unembed writes an embedded image to a file it then links to
//! (`links.unembed`). //! (`links.unembed`).
//! //!
//! Placed documents ([`PlacedDocument`], VectorCraft documents placed with Link) link to their //! Placed documents ([`PlacedDocument`], VectorCraft documents and PDF or `.ai` pages placed with
//! files the same way: checking, updating, relinking and finding them on open work alike, and they //! Link) link to their files the same way: checking, updating, relinking and finding them on open
//! are listed. Embed (Break Link) turns one into an editable copy of its art. //! work alike, and they are listed. Embed (Break Link) turns one into an editable copy of its art.
use std::collections::BTreeMap; use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path}; use std::path::{Component, Path};
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacedDocument, PlacementOptions}; use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_geom::{Affine, Rect, shapes}; use vectorcraft_geom::{Affine, Rect, shapes};
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
@@ -66,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Relink", "Relink",
[], [],
None, None,
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
has_doc, has_doc,
relink relink
), ),
@@ -111,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Link Info", "Link Info",
[], [],
None, None,
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size", "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size",
has_doc, has_doc,
info info
), ),
@@ -148,21 +149,25 @@ impl Status {
} }
/// The image objects that show one linked file as one content (same path, same image), or the /// The image objects that show one linked file as one content (same path, same image), or the
/// placed documents that show one artboard of it. /// placed documents that show one artboard or page of it.
struct Group { struct Group {
link: LinkInfo, link: LinkInfo,
key: String, key: String,
/// The objects in the layers (images in symbols and patterns share the content, not an id). /// The objects in the layers (images in symbols and patterns share the content, not an id).
ids: Vec<NodeId>, ids: Vec<NodeId>,
/// Placed documents: whether the art's bounds (not the artboard) are shown. /// Placed documents: what they show.
document: Option<bool>, document: Option<Shown>,
} }
/// The linked object `n` as (link, content key, placed document's bounding). /// What a placed document shows: whether the art's bounds (not the artboard or page) are shown,
fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> { /// and a PDF page's box.
type Shown = (bool, PageBox);
/// The linked object `n` as (link, content key, what a placed document shows).
fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<Shown>)> {
match &n.kind { match &n.kind {
NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)), NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)),
NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some(p.bounding))), NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some((p.bounding, p.page_box)))),
_ => None, _ => None,
} }
} }
@@ -177,7 +182,7 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> { fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
let mut out: Vec<Group> = vec![]; let mut out: Vec<Group> = vec![];
let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new();
let mut add = |link: &LinkInfo, key: &str, document: Option<bool>, id: Option<NodeId>| { let mut add = |link: &LinkInfo, key: &str, document: Option<Shown>, id: Option<NodeId>| {
let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| { let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| {
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document }); out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document });
out.len() - 1 out.len() - 1
@@ -199,7 +204,7 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
add(l, &im.key, None, None); add(l, &im.key, None, None);
} }
}); });
d.visit_placed(|_, p| add(&p.link, &p.key, Some(p.bounding), None)); d.visit_placed(|_, p| add(&p.link, &p.key, Some((p.bounding, p.page_box)), None));
} }
out out
} }
@@ -282,6 +287,18 @@ fn previewing(d: &Document, key: &str) -> bool {
d.images.get(key).is_none_or(ImageBlob::is_proxy) d.images.get(key).is_none_or(ImageBlob::is_proxy)
} }
/// A linked raster image whose file is an EPS (Preferences › File Handling › Use Low Resolution
/// Proxy for Linked EPS).
fn linked_eps(link: &LinkInfo) -> bool {
fileio::format_for_name(link.name()).is_some_and(|f| f.id == "eps")
}
/// With `low_res_proxy_eps`, a linked EPS image that already shows its saved preview keeps it on
/// open instead of reading the file again (Update Links still reads modified files).
fn keep_eps_preview(low_res_proxy_eps: bool, g: &Group, d: &Document) -> bool {
low_res_proxy_eps && g.document.is_none() && linked_eps(&g.link) && previewing(d, &g.key)
}
/// Does group `g` of `d` show something other than its file: an image its preview, a placed /// Does group `g` of `d` show something other than its file: an image its preview, a placed
/// document nothing (its file's bytes are gone)? /// document nothing (its file's bytes are gone)?
fn stale(d: &Document, g: &Group) -> bool { fn stale(d: &Document, g: &Group) -> bool {
@@ -320,6 +337,47 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
.collect() .collect()
} }
/// `d` for output: linked raster images that still show only their saved preview read their files
/// again (Preferences › Use Low Resolution Proxy for Linked EPS keeps the preview on screen only).
pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec<String>) {
let mut keys = BTreeSet::new();
d.visit_images(|_, im| {
if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) {
keys.insert(im.key.clone());
}
});
if keys.is_empty() {
return (Cow::Borrowed(d), vec![]);
}
let mut out = d.clone();
let mut previews = BTreeSet::new();
let all = groups(d, None);
for key in keys {
let Some(g) = all.iter().find(|g| g.document.is_none() && g.key == key) else { continue };
let Some(mut f) = locate(&g.link, doc_path) else {
previews.insert(key);
continue;
};
let Some(bytes) = f.read().ok() else {
previews.insert(key);
continue;
};
let Ok(img) = fileio::raster_image(&bytes) else {
previews.insert(key);
continue;
};
let preview = d.images.get(&key).map(|b| b.bytes.clone());
let mime = fileio::format_for_name(g.link.name()).map(|f| f.mime.to_string()).unwrap_or_else(|| img.blob.mime.clone());
out.images.insert(key, ImageBlob { mime, bytes: img.blob.bytes, proxy: preview });
}
let mut warnings = vec![];
if !previews.is_empty() {
warnings
.push(format!("{} linked image(s) output as their previews: their files can't be found or read (relink or update them)", previews.len()));
}
(Cow::Owned(out), warnings)
}
/// The linked files of the open document `st` that `links.findFiles` looks for, each with the /// The linked files of the open document `st` that `links.findFiles` looks for, each with the
/// objects in the layers that show it: the files of objects `ids` whatever their status, else the /// objects in the layers that show it: the files of objects `ids` whatever their status, else the
/// files that can't be found. /// files that can't be found.
@@ -355,16 +413,17 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
/// A linked file read for the objects that show it. /// A linked file read for the objects that show it.
enum Read { enum Read {
Image(FileRead), Image(FileRead),
/// A VectorCraft document read for placed documents: the link to it and what they show. /// A VectorCraft document or a PDF or `.ai` file read for placed documents: the link to it and
/// what they show.
Document(LinkInfo, super::place::document::Source), Document(LinkInfo, super::place::document::Source),
} }
/// The file at `f` read for group `g`: its image, or the artboard `g` shows. /// The file at `f` read for group `g`: its image, or the artboard or page `g` shows.
fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> { fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
let Some(bounding) = g.document else { return read_image(f, &g.link).map(Read::Image) }; let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link).map(Read::Image) };
let bytes = f.read()?; let bytes = f.read()?;
let page = g.link.page.unwrap_or(1); let page = g.link.page.unwrap_or(1);
let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, cmd)?; let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, page_box, cmd)?;
Ok(Read::Document(f.link(&bytes, &g.link), src)) Ok(Read::Document(f.link(&bytes, &g.link), src))
} }
@@ -554,11 +613,12 @@ impl Resolved {
/// away from their path take the new path. A link nothing is known of whose image shows a preview /// away from their path take the new path. A link nothing is known of whose image shows a preview
/// (a file another app wrote kept only that) was never read: its file is read now, into the /// (a file another app wrote kept only that) was never read: its file is read now, into the
/// image's bounds, which isn't a change to report. Nothing here is an undo step. /// image's bounds, which isn't a change to report. Nothing here is an undo step.
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved { pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved {
let mut out = Resolved::default(); let mut out = Resolved::default();
for g in groups(d, None) { for g in groups(d, None) {
let row = rows(std::iter::once(&g), doc_path); let row = rows(std::iter::once(&g), doc_path);
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key); let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d);
match probe(&g, doc_path) { match probe(&g, doc_path) {
(Status::Missing, _) => out.missing.extend(row), (Status::Missing, _) => out.missing.extend(row),
(Status::Modified, Some(mut f)) if update || unread => { (Status::Modified, Some(mut f)) if update || unread => {
@@ -593,8 +653,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv
(Status::Ok, Some(mut f)) => { (Status::Ok, Some(mut f)) => {
let preview = previewing(d, &g.key); let preview = previewing(d, &g.key);
// Read when the images show the preview, or when the file's details changed. // Read when the images show the preview, or when the file's details changed.
let bytes = if preview || f.bytes.is_some() { f.read().ok() } else { None }; let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None };
if preview { if preview && !eps_preview {
let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else { let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else {
out.missing.extend(row); out.missing.extend(row);
continue; continue;
@@ -834,13 +894,13 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let (mut copied, mut kept) = (vec![], vec![]); let (mut copied, mut kept) = (vec![], vec![]);
for (ids, path) in files { for (ids, path) in files {
let bytes = read_file(&path)?; let bytes = read_file(&path)?;
// Images show the file; placed documents each show their artboard of it. // Images show the file; placed documents each show their artboard or page of it.
let mut images = vec![]; let mut images = vec![];
let mut documents: Vec<((u32, bool), Vec<NodeId>)> = vec![]; let mut documents: Vec<((u32, bool, PageBox), Vec<NodeId>)> = vec![];
for id in ids { for id in ids {
match st.doc.node(id).map(|n| &n.kind) { match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::PlacedDocument(pl)) => { Some(NodeKind::PlacedDocument(pl)) => {
let k = (pl.page(), pl.bounding); let k = (pl.page(), pl.bounding, pl.page_box);
match documents.iter_mut().find(|(e, _)| *e == k) { match documents.iter_mut().find(|(e, _)| *e == k) {
Some((_, ids)) => ids.push(id), Some((_, ids)) => ids.push(id),
None => documents.push((k, vec![id])), None => documents.push((k, vec![id])),
@@ -852,8 +912,8 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
// Read before it is copied: a file that can't be shown isn't copied. // Read before it is copied: a file that can't be shown isn't copied.
let img = if images.is_empty() { None } else { Some(fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) }; let img = if images.is_empty() { None } else { Some(fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) };
let mut sources = vec![]; let mut sources = vec![];
for ((page, bounding), ids) in documents { for ((page, bounding, page_box), ids) in documents {
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?)); sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?));
} }
let to = match &copy_to { let to = match &copy_to {
Some(dir) => { Some(dir) => {
@@ -997,10 +1057,12 @@ fn format_label(d: &Document, im: &ImageObject) -> &'static str {
.map_or("Image", |f| f.label) .map_or("Image", |f| f.label)
} }
/// A placed document's `links.list` fields: `format`, `document: true` and its artboard's size /// A placed document's `links.list` fields: `format` (`VectorCraft`, or the PDF or `.ai` file's
/// (`pageWidth`, `pageHeight`, pt) instead of pixels. /// format), `document: true` and its artboard's or page's size (`pageWidth`, `pageHeight`, pt)
/// instead of pixels.
fn document_row(p: &PlacedDocument) -> Value { fn document_row(p: &PlacedDocument) -> Value {
json!({ "format": "VectorCraft", "document": true, "pageWidth": p.width, "pageHeight": p.height }) let pdf = fileio::format_for_name(p.link.name()).filter(|f| matches!(f.id, "pdf" | "ai" | "ait"));
json!({ "format": pdf.map_or("VectorCraft", |f| f.label), "document": true, "pageWidth": p.width, "pageHeight": p.height })
} }
/// Sort rank of a `links.list` status: missing, modified, ok, embedded. /// Sort rank of a `links.list` status: missing, modified, ok, embedded.
+49 -7
View File
@@ -10,8 +10,8 @@ use std::sync::Arc;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint}; use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::live::{ use vectorcraft_doc::live::{
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance, self, BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions,
PreserveShape, Spine, GradientMesh, MeshAppearance, PreserveShape, Spine,
}; };
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make", "Make",
["Object", "Blend"], ["Object", "Blend"],
Some("Cmd+Alt+B"), Some("Cmd+Alt+B"),
"{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}",
has_doc, has_doc,
blend_make blend_make
), ),
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Options…", "Blend Options…",
["Object", "Blend"], ["Object", "Blend"],
None, None,
"{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}", "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}",
always, always,
blend_options blend_options
), ),
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Info", "Blend Info",
[], [],
None, None,
"{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}",
always, always,
blend_info blend_info
), ),
@@ -451,6 +451,32 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation {
} }
} }
/// Blend panel Type and Ramp, validated before any document mutation.
fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result<BlendEase> {
let kind = match p.get("easingType").filter(|v| !v.is_null()) {
None => current.kind,
Some(v) => match v.as_str() {
Some("easeIn") => BlendEaseType::In,
Some("easeOut") => BlendEaseType::Out,
Some("easeInOut") => BlendEaseType::InOut,
Some("easeOutIn") => BlendEaseType::OutIn,
_ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")),
},
};
Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 })
}
/// Bounded percentage input; missing or null preserves the current value.
fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result<f64> {
match p.get(key).filter(|v| !v.is_null()) {
None => Ok(current),
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && (min..=100.0).contains(n))
.ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))),
}
}
/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from. /// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from.
fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> { fn starts_param(p: &Value, roots: &[NodeId]) -> Result<Vec<(NodeId, u32)>> {
const C: &str = "object.blend.make"; const C: &str = "object.blend.make";
@@ -533,6 +559,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
}; };
let spacing = spacing_param(p, C, cur.spacing)?; let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation); let orientation = orientation_param(p, cur.orientation);
let (ease0, color0) = match base.as_ref().map(|n| &n.kind) {
Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift),
_ => Default::default(),
};
let ease = ease_param(p, C, ease0)?;
let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?;
let id = s.edit("Make Blend", |d, sel| { let id = s.edit("Make Blend", |d, sel| {
wrap(d, sel, &roots, |id, nodes| { wrap(d, sel, &roots, |id, nodes| {
let (children, starts) = merge_keys(nodes, &starts); let (children, starts) = merge_keys(nodes, &starts);
@@ -543,7 +575,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result<Value> {
n.knockout = vectorcraft_doc::Knockout::On; n.knockout = vectorcraft_doc::Knockout::On;
n n
}); });
let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() }; let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() };
if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind {
extend_spine(&mut spec, old, old_spec, &children); extend_spine(&mut spec, old, old_spec, &children);
} }
@@ -772,7 +804,10 @@ fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec<NodeId>)> {
fn blend_options(s: &mut Session, p: &Value) -> Result<Value> { fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.options"; const C: &str = "object.blend.options";
let Some((_, cur, _)) = selected_spec(s) else { let Some((_, cur, _)) = selected_spec(s) else {
// Nothing to set them on: the options new blends start with. if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) {
return Err(bad(C, "select a blend to set easing or color shift"));
}
// Nothing to set them on: the options new blends start with (easing is per blend).
let d = s.prefs.blend_options.unwrap_or_default(); let d = s.prefs.blend_options.unwrap_or_default();
let spacing = spacing_param(p, C, d.spacing)?; let spacing = spacing_param(p, C, d.spacing)?;
s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) });
@@ -780,9 +815,13 @@ fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
}; };
let spacing = spacing_param(p, C, cur.spacing)?; let spacing = spacing_param(p, C, cur.spacing)?;
let orientation = orientation_param(p, cur.orientation); let orientation = orientation_param(p, cur.orientation);
let ease = ease_param(p, C, cur.ease)?;
let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?;
edit_blends(s, "Blend Options", |_, spec| { edit_blends(s, "Blend Options", |_, spec| {
spec.spacing = spacing; spec.spacing = spacing;
spec.orientation = orientation; spec.orientation = orientation;
spec.ease = ease;
spec.color_shift = color_shift;
}) })
} }
@@ -804,6 +843,9 @@ fn blend_info(s: &mut Session, _: &Value) -> Result<Value> {
let mut v = blend_options_json(spec.spacing, spec.orientation); let mut v = blend_options_json(spec.spacing, spec.orientation);
v["target"] = json!("blend"); v["target"] = json!("blend");
v["id"] = json!(id.0); v["id"] = json!(id.0);
v["easingType"] = json!(spec.ease.kind.id());
v["ramp"] = json!(spec.ease.ramp * 100.0);
v["colorShift"] = json!(spec.color_shift);
v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>()); v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::<Vec<_>>());
v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>()); v["keys"] = json!(keys.iter().map(|k| k.0).collect::<Vec<_>>());
if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind)
+2
View File
@@ -353,6 +353,7 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result<Value> {
let random = switch_or(p, "random", false, "object.transformEach")?; let random = switch_or(p, "random", false, "object.transformEach")?;
let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize; let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize;
let copy = switch_or(p, "copy", false, "object.transformEach")?; let copy = switch_or(p, "copy", false, "object.transformEach")?;
check_switches(p, &["strokes", "corners", "objects", "patterns"], "object.transformEach")?;
let objects = super::object::transforms_objects(p); let objects = super::object::transforms_objects(p);
if sh == 0.0 || sv == 0.0 { if sh == 0.0 || sv == 0.0 {
return Err(bad("object.transformEach", "scale must be non-zero")); return Err(bad("object.transformEach", "scale must be non-zero"));
@@ -1018,6 +1019,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["spacing"], CMD)?; check_numbers(p, &["spacing"], CMD)?;
let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5); let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5);
let move_art = switch_or(p, "moveArtwork", true, CMD)?; let move_art = switch_or(p, "moveArtwork", true, CMD)?;
check_switches(p, &["patterns"], CMD)?;
let locked_and_hidden = s.prefs.move_locked_with_artboard; let locked_and_hidden = s.prefs.move_locked_with_artboard;
let d = &s.doc()?.doc; let d = &s.doc()?.doc;
let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect(); let rects: Vec<Rect> = d.artboards.iter().map(|a| a.rect).collect();
+1 -1
View File
@@ -50,7 +50,7 @@ mod object;
pub(crate) mod objects_on_path; pub(crate) mod objects_on_path;
mod opacitymask; mod opacitymask;
mod overprint; mod overprint;
mod package; pub mod package;
mod paint; mod paint;
mod panelcmds; mod panelcmds;
pub mod params; pub mod params;
+43 -13
View File
@@ -252,8 +252,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Align", "Align",
["Window", "Align"], ["Window", "Align"],
None, None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)",
has_selection, has_selection_or_guides,
align align
), ),
cmd!( cmd!(
@@ -1155,6 +1155,7 @@ fn data_param(m: &serde_json::Map<String, Value>) -> Result<Vec<(String, Option<
fn set_props(s: &mut Session, p: &Value) -> Result<Value> { fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?; check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?;
check_numbers(p, &["opacity"], "object.setProps")?;
let ids = targets(s, p)?; let ids = targets(s, p)?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(percent); let opacity = p.get("opacity").and_then(Value::as_f64).map(percent);
let blend = match str_param(p, "blend") { let blend = match str_param(p, "blend") {
@@ -1361,11 +1362,22 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: O
Ok((r, true, true)) Ok((r, true, true))
} }
/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to
/// Selection when no objects are selected.
fn selected_guides_bounds(st: &crate::DocState) -> Option<Rect> {
st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option<Rect>, g| {
let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) };
Some(acc.map(|a| a.union(r)).unwrap_or(r))
})
}
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> { fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
let st = s.doc()?; let st = s.doc()?;
match align_to(s, p) { match align_to(s, p) {
Some("artboard") => { Some("artboard") => {
let b = effects::preview::bounds_of(&st.doc, ids, preview).unwrap_or_default(); let b = effects::preview::bounds_of(&st.doc, ids, preview)
.or_else(|| ids.is_empty().then(|| selected_guides_bounds(st)).flatten())
.unwrap_or_default();
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without // Align to Artboard uses artboard `artboard` (the app passes the active one). Without
// it, or with an index the document doesn't have, it uses the artboard under the center // it, or with an index the document doesn't have, it uses the artboard under the center
// of the selection, or the first one when no artboard is there. // of the selection, or the first one when no artboard is there.
@@ -1377,7 +1389,13 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?; let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k)) effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k))
} }
_ => effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())), _ => {
if ids.is_empty() {
selected_guides_bounds(st).ok_or_else(|| EngineError::Other("nothing to align".into()))
} else {
effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into()))
}
}
} }
} }
@@ -1407,6 +1425,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
let (h, v) = direction(p, "object.align")?; let (h, v) = direction(p, "object.align")?;
choice(p, "object.align", "to", &["selection", "artboard", "key"])?; choice(p, "object.align", "to", &["selection", "artboard", "key"])?;
check_switches(p, &["patterns"], "object.align")?; check_switches(p, &["patterns"], "object.align")?;
check_numbers(p, &["artboard"], "object.align")?;
if h.is_none() && v.is_none() { if h.is_none() && v.is_none() {
return Err(bad("object.align", "give horizontal or vertical")); return Err(bad("object.align", "give horizontal or vertical"));
} }
@@ -1443,15 +1462,22 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
apply_moves(s, "Align", p, &ids, &moves, &guides) apply_moves(s, "Align", p, &ids, &moves, &guides)
} }
/// The selected ruler guides that Align to the key object (or key guide) moves, and where: a /// The selected ruler guides that Align moves, and where: a vertical guide goes to the left edge,
/// vertical guide goes to the left edge, centre or right edge of `r` that `h` names, a horizontal /// centre or right edge of `r` that `h` names, a horizontal one to the top, centre or bottom that
/// one to the top, centre or bottom that `v` names. The key guide stays put, and so do guides /// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put.
/// when aligning to the selection or the artboard. /// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are
/// selected; with objects in the selection they stay put.
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(usize, f64)>> { fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(usize, f64)>> {
if align_to(s, p) != Some("key") { let st = s.doc()?;
let to = align_to(s, p);
let move_guides = match to {
Some("key" | "artboard") => true,
Some("selection") => st.selection.is_empty(),
_ => false,
};
if !move_guides {
return Ok(vec![]); return Ok(vec![]);
} }
let st = s.doc()?;
let at = |vertical: bool| match (vertical, h, v) { let at = |vertical: bool| match (vertical, h, v) {
(true, Some("left"), _) => Some(r.x0), (true, Some("left"), _) => Some(r.x0),
(true, Some("center"), _) => Some(r.center().x), (true, Some("center"), _) => Some(r.center().x),
@@ -1461,11 +1487,12 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>
(false, _, Some("bottom")) => Some(r.y1), (false, _, Some("bottom")) => Some(r.y1),
_ => None, _ => None,
}; };
let skip_key = to == Some("key");
Ok(st Ok(st
.selection .selection
.guides .guides
.iter() .iter()
.filter(|&&i| Some(i) != st.selection.key_guide) .filter(|&&i| !skip_key || Some(i) != st.selection.key_guide)
.filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?))) .filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?)))
.collect()) .collect())
} }
@@ -1626,6 +1653,7 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option<f64
fn distribute(s: &mut Session, p: &Value) -> Result<Value> { fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distribute")?; check_switches(p, &["patterns"], "object.distribute")?;
check_numbers(p, &["artboard"], "object.distribute")?;
let ids = selected_roots(s)?; let ids = selected_roots(s)?;
let preview = preview_bounds(s, p, "object.distribute")?; let preview = preview_bounds(s, p, "object.distribute")?;
let board = distribute_to(s, p, &ids, preview, "object.distribute")?; let board = distribute_to(s, p, &ids, preview, "object.distribute")?;
@@ -1700,6 +1728,7 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> { fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "object.distributeSpacing")?; check_switches(p, &["patterns"], "object.distributeSpacing")?;
check_numbers(p, &["artboard"], "object.distributeSpacing")?;
let ids = selected_roots(s)?; let ids = selected_roots(s)?;
let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical"); let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical");
let preview = preview_bounds(s, p, "object.distributeSpacing")?; let preview = preview_bounds(s, p, "object.distributeSpacing")?;
@@ -1786,7 +1815,7 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(
/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the /// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the
/// art with a width or height below `least` of the one it has keeps `least` of it. /// art with a width or height below `least` of the one it has keeps `least` of it.
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> { fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
check_numbers(p, &["x", "y", "width", "height"], "object.setBounds")?; check_numbers(p, &["x", "y", "width", "height", "reference"], "object.setBounds")?;
let st = s.doc()?; let st = s.doc()?;
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?; check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
check_switches(p, &["proportional"], "object.setBounds")?; check_switches(p, &["proportional"], "object.setBounds")?;
@@ -2321,7 +2350,8 @@ fn corners_param(p: &Value) -> Result<Option<BTreeSet<usize>>> {
} }
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> { fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["invertPie", "makeSidesEqual"], "object.setLiveShape")?; check_switches(p, &["invertPie", "makeSidesEqual", "relativeRadii"], "object.setLiveShape")?;
check_numbers(p, &["radius", "sides"], "object.setLiveShape")?;
// `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them. // `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them.
let items = match p.get("items").filter(|v| !v.is_null()) { let items = match p.get("items").filter(|v| !v.is_null()) {
None => None, None => None,
+162 -19
View File
@@ -4,7 +4,7 @@
//! report). The packaged document links to the copies (Relink); the open document doesn't change. //! report). The packaged document links to the copies (Relink); the open document doesn't change.
//! Without a folder (the web, agents) the same files come back as a zip archive. //! Without a folder (the web, agents) the same files come back as a zip archive.
use std::collections::{BTreeMap, HashSet}; use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -101,6 +101,34 @@ fn safe_asset_name(name: &str) -> String {
if name.is_empty() { "asset".into() } else { name.into() } if name.is_empty() { "asset".into() } else { name.into() }
} }
/// A file placed: (page, bounding, page box) of each placed object showing it.
type PlacedPages = Vec<(u32, bool, vectorcraft_doc::PageBox)>;
#[cfg(test)]
thread_local! {
/// Tests: how many placed PDF or `.ai` pages [`page_fonts`] read on this thread.
pub(crate) static PAGES_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Tests: how many linked `.vectorcraft` files had their preview and PDF read on this thread,
/// to keep them in the rewritten copy.
pub(crate) static PARTS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The fonts of the type on `pages` of the PDF or `.ai` file `name` (`bytes`, found at `path`);
/// none for other files.
fn page_fonts(name: &str, bytes: &[u8], path: &str, pages: &PlacedPages) -> BTreeSet<super::fonts::UsedFont> {
let mut fonts = BTreeSet::new();
if super::place::document::is_pdf(name, bytes) {
for &(page, bounding, page_box) in pages {
#[cfg(test)]
PAGES_READ.with(|c| c.set(c.get() + 1));
if let Ok(a) = super::place::document::document_art(name, bytes, Some(path), page, bounding, page_box, C) {
fonts.extend(super::fonts::art_fonts(&a.doc, &a.nodes));
}
}
}
fonts
}
/// Collect dependencies depth-first so nested VectorCraft files are rewritten before /// Collect dependencies depth-first so nested VectorCraft files are rewritten before
/// their parents: each parent can record the final hash of the rewritten child. /// their parents: each parent can record the final hash of the rewritten child.
/// Cycles, excessive nesting, and unreadable children are reported, not fatal. /// Cycles, excessive nesting, and unreadable children are reported, not fatal.
@@ -148,7 +176,20 @@ impl<'a> Collector<'a> {
self.warnings.push(message); self.warnings.push(message);
} }
fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize) -> Result<()> { /// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the
/// copy of `source` finds it only by its name, beside it.
fn renamed(&mut self, lines: &mut Vec<String>, source: &str, path: &str, name: &str, copy: &str) {
if Path::new(copy).file_name().and_then(|n| n.to_str()) != Some(name) {
let message = format!(
"{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview"
);
self.warn(lines, message);
}
}
/// Collects the files `doc` links. `by_name`: `doc` is a placed `.ai` file's editing data,
/// whose links are not rewritten, so its copy finds them by name beside it.
fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize, by_name: bool) -> Result<()> {
// The caller checks cycles and depth before descending; active documents have // The caller checks cycles and depth before descending; active documents have
// a destination reserved so back-links can point at their single packaged copy. // a destination reserved so back-links can point at their single packaged copy.
self.visiting.insert(source.to_string(), destination.to_string()); self.visiting.insert(source.to_string(), destination.to_string());
@@ -167,11 +208,24 @@ impl<'a> Collector<'a> {
// Different linked documents can reuse the same stale absolute path yet // Different linked documents can reuse the same stale absolute path yet
// resolve it to distinct local assets. Keep a mapping per document. // resolve it to distinct local assets. Keep a mapping per document.
let mut local = BTreeMap::<String, Copied>::new(); let mut local = BTreeMap::<String, Copied>::new();
// The fonts of the type on the PDF and `.ai` pages placed linked.
let mut placed_fonts = BTreeSet::new();
let mut placed = BTreeMap::<String, PlacedPages>::new();
doc.visit_placed(|_, p| {
let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box);
let pages = placed.entry(p.link.path.clone()).or_default();
if !pages.contains(&page) {
pages.push(page);
}
});
if self.opts.copy_fonts && !self.opts.copy_links && !placed.is_empty() {
for file in super::links::linked_files(doc, Some(source)) {
if let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) {
placed_fonts.extend(page_fonts(&file.name, bytes, file.found_path.as_deref().unwrap_or(&file.path), pages));
}
}
}
if self.opts.copy_links { if self.opts.copy_links {
let mut placed = HashSet::new();
doc.visit_placed(|_, p| {
placed.insert(p.link.path.clone());
});
heading(&mut lines, "LINKED FILES"); heading(&mut lines, "LINKED FILES");
for file in super::links::linked_files(doc, Some(source)) { for file in super::links::linked_files(doc, Some(source)) {
let found_path = file.found_path.as_deref().unwrap_or(&file.path); let found_path = file.found_path.as_deref().unwrap_or(&file.path);
@@ -183,8 +237,17 @@ impl<'a> Collector<'a> {
local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None }); local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None });
continue; continue;
} }
if let Some(existing) = self.copies.get(found_path) { if let Some(existing) = self.copies.get(found_path).cloned() {
local.insert(file.path.clone(), existing.clone()); if by_name {
self.renamed(&mut lines, source, &file.path, &file.name, &existing.name);
}
// Another document may place other pages of a PDF copied already.
if self.opts.copy_fonts
&& let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes)
{
placed_fonts.extend(page_fonts(&file.name, bytes, found_path, pages));
}
local.insert(file.path.clone(), existing);
continue; continue;
} }
let Some(mut bytes) = file.bytes else { let Some(mut bytes) = file.bytes else {
@@ -199,24 +262,42 @@ impl<'a> Collector<'a> {
let name = safe_asset_name(&file.name); let name = safe_asset_name(&file.name);
let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken }); let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken });
let destination_file = format!("{dir}{unique}"); let destination_file = format!("{dir}{unique}");
if placed.contains(&file.path) { if by_name {
self.renamed(&mut lines, source, &file.path, &file.name, &destination_file);
}
// A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file
// read from its editing data links are copied beside it.
let pages = placed.get(&file.path);
let placed_file = pages.is_some();
if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) {
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
} else {
self.collect(&mut art, found_path, &destination_file, depth + 1, true)?;
}
} else if self.opts.copy_fonts
&& let Some(pages) = pages
{
placed_fonts.extend(page_fonts(&file.name, &bytes, found_path, pages));
}
if placed_file && super::place::document::is_native(&file.name, &bytes) {
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
} else { } else {
match vectorcraft_format::load_file(&bytes) { match vectorcraft_format::load_file(&bytes) {
Ok(saved) => { Ok(saved) => {
let preview = vectorcraft_format::preview(&bytes);
let pdf = vectorcraft_format::pdf_content(&bytes);
let compressed = bytes.starts_with(&[0x1f, 0x8b]); let compressed = bytes.starts_with(&[0x1f, 0x8b]);
let include_linked = let include_linked =
saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy())); saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy()));
let mut nested = saved.doc; let mut nested = saved.doc;
self.collect(&mut nested, found_path, &destination_file, depth + 1)?; self.collect(&mut nested, found_path, &destination_file, depth + 1, false)?;
if self.opts.relink { if self.opts.relink {
#[cfg(test)]
PARTS_READ.with(|c| c.set(c.get() + 1));
// Preserve auxiliary native-file content when rewriting. // Preserve auxiliary native-file content when rewriting.
let mut options = vectorcraft_format::SaveOptions::for_doc(&nested); let mut options = vectorcraft_format::SaveOptions::for_doc(&nested);
options.preview = preview; options.preview = vectorcraft_format::preview(&bytes);
options.pdf = pdf; options.pdf = vectorcraft_format::pdf_content(&bytes);
options.profiles = saved.profiles; options.profiles = saved.profiles;
options.compress = compressed; options.compress = compressed;
options.include_linked = include_linked; options.include_linked = include_linked;
@@ -250,7 +331,9 @@ impl<'a> Collector<'a> {
if self.opts.copy_fonts { if self.opts.copy_fonts {
let db = vectorcraft_text::FontDb::global(); let db = vectorcraft_text::FontDb::global();
heading(&mut lines, "FONTS"); heading(&mut lines, "FONTS");
for used in super::fonts::used_fonts(doc) { let mut used_fonts = super::fonts::used_fonts(doc);
used_fonts.append(&mut placed_fonts);
for used in used_fonts {
let font = super::fonts::font_label(&used); let font = super::fonts::font_label(&used);
let (family, style, version) = used; let (family, style, version) = used;
// Found by any of its names, as the canvas draws it (in the version the type names). // Found by any of its names, as the canvas draws it (in the version the type names).
@@ -306,6 +389,51 @@ impl<'a> Collector<'a> {
} }
fn package(s: &mut Session, p: &Value) -> Result<Value> { fn package(s: &mut Session, p: &Value) -> Result<Value> {
let package = prepare(s, p)?;
match &mut s.package_hand_off {
Some(slot) => {
*slot = Some(package);
Ok(Value::Null)
}
None => package.write(&mut |path, bytes| write_file(path, bytes)),
}
}
/// A package of the active document as `file.package` takes it from the session: the options, the
/// names, the report's Document Info text and the document as it is now
/// ([`Session::prepare_package`]). [`Package::write`] collects and writes it without the session,
/// on any thread.
pub struct Package {
o: Options,
doc_path: String,
stem: String,
name: String,
root: Option<PathBuf>,
info: Option<String>,
doc: std::sync::Arc<Document>,
}
impl Session {
/// Run `file.package` with `params` as a command (checked and journaled as any other) that
/// collects and writes nothing → the package it prepared, which [`Package::write`] collects
/// and writes (on the desktop with Background Export on, File › Package… does this on the
/// background thread).
pub fn prepare_package(&mut self, params: &Value) -> Result<Package> {
self.package_hand_off = Some(None);
let r = self.execute(C, params);
let handed = self.package_hand_off.take().flatten();
match (r, handed) {
(Err(e), _) => Err(e),
(Ok(_), Some(package)) => Ok(package),
// Every run that succeeds hands one over; otherwise the package is prepared here.
(Ok(_), None) => prepare(self, params),
}
}
}
/// What `file.package` reads from the session: the options, the checks of the document and the
/// name, the report's Document Info text and the document.
fn prepare(s: &mut Session, p: &Value) -> Result<Package> {
let o = Options { let o = Options {
copy_links: bool_or(p, "copyLinks", true), copy_links: bool_or(p, "copyLinks", true),
links_folder: bool_or(p, "linksFolder", true), links_folder: bool_or(p, "linksFolder", true),
@@ -322,8 +450,23 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
let folder = str_param(p, "folder"); let folder = str_param(p, "folder");
let root = folder.map(|f| Path::new(f).join(&name)); let root = folder.map(|f| Path::new(f).join(&name));
let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None }; let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None };
let st = s.doc()?; let doc = s.doc()?.doc.clone();
let mut doc = (*st.doc).clone(); Ok(Package { o, doc_path, stem, name, root, info, doc })
}
impl Package {
/// Collect the linked files and fonts and write the package's files with `write` into
/// `folder/name`, making its folders first, or into the zip without a folder → the reply of
/// `file.package`.
pub fn write(self, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
collect_and_write(self, write)
}
}
/// [`Package::write`].
fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let Package { o, doc_path, stem, name, root, info, doc } = package;
let mut doc = (*doc).clone();
let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION); let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION);
// Prepare original links for the destination *before* rebasing copied links. // Prepare original links for the destination *before* rebasing copied links.
// Recalculating afterward would overwrite the portable relative paths. // Recalculating afterward would overwrite the portable relative paths.
@@ -333,7 +476,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
doc = d; doc = d;
} }
let mut collector = Collector::new(&o, root.clone(), &doc_name); let mut collector = Collector::new(&o, root.clone(), &doc_name);
collector.collect(&mut doc, &doc_path, &doc_name, 0)?; collector.collect(&mut doc, &doc_path, &doc_name, 0, false)?;
let mut entries = std::mem::take(&mut collector.entries); let mut entries = std::mem::take(&mut collector.entries);
entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc))); entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc)));
let mut taken = std::mem::take(&mut collector.taken); let mut taken = std::mem::take(&mut collector.taken);
@@ -366,7 +509,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(dir) = path.parent() { if let Some(dir) = path.parent() {
create_dir(&dir.to_string_lossy())?; create_dir(&dir.to_string_lossy())?;
} }
write_file(&path.to_string_lossy(), bytes)?; write(&path.to_string_lossy(), bytes)?;
} }
out["folder"] = json!(root.to_string_lossy()); out["folder"] = json!(root.to_string_lossy());
} }
+7 -1
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill", "Fill",
[], [],
None, None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only", "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
has_doc, has_doc,
|s, p| set_paint(s, p, true) |s, p| set_paint(s, p, true)
), ),
@@ -351,6 +351,7 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
}; };
} }
if let Some(g) = p.get("gradient") { if let Some(g) = p.get("gradient") {
check_numbers(g, &["angle"], "paint")?;
// A stop naming a process swatch keeps the swatch's model. // A stop naming a process swatch keeps the swatch's model.
let stored = super::gradient::stop_colors(s, p, g); let stored = super::gradient::stop_colors(s, p, g);
let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?; let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?;
@@ -402,6 +403,11 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
} }
check_keep_model(p, cmd)?; check_keep_model(p, cmd)?;
let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?; let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?;
// As `paint.editGradient`: a `dither` given applies to gradient fills only. A gradient swatch
// (named) keeps its own dither; `"swatch": null` names none.
if !fill && matches!(&paint, Paint::Gradient(g) if g.gradient.dither) && p.get("swatch").and_then(Value::as_str).is_none() {
return Err(bad(cmd, "`dither` applies to gradient fills (paint.setFill)"));
}
apply_paint(s, p, paint, fill) apply_paint(s, p, paint, fill)
} }
+8 -1
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph", "Character / Paragraph",
[], [],
None, None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc, has_doc,
set_format set_format
), ),
@@ -72,6 +72,7 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
/// copy commands: by default the artboards the Artboard tool has selected (its active one when none /// copy commands: by default the artboards the Artboard tool has selected (its active one when none
/// of them is in the document) and its Move/Copy Artwork with Artboard option. /// of them is in the document) and its Move/Copy Artwork with Artboard option.
fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> { fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
check_switches(p, &["art"], cmd)?;
let t = s.tool_options_of("artboard"); let t = s.tool_options_of("artboard");
let d = &s.doc()?.doc; let d = &s.doc()?.doc;
let indices = if p.get("index").is_some() || p.get("indices").is_some() { let indices = if p.get("index").is_some() || p.get("indices").is_some() {
@@ -159,6 +160,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec
/// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep /// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep
/// their places relative to each other, together right of the last artboard. /// their places relative to each other, together right of the last artboard.
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> { fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
check_switches(p, &["patterns"], "artboard.duplicate")?;
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?; let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
let arts = artboards_art(s, &indices, art, false)?; let arts = artboards_art(s, &indices, art, false)?;
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect(); let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
@@ -327,6 +329,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"direction", "direction",
"leadingModel", "leadingModel",
"charAlign", "charAlign",
"charDirection",
"proportionalMetrics", "proportionalMetrics",
"tateChuYoko", "tateChuYoko",
"noBreak", "noBreak",
@@ -350,6 +353,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
}; };
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?; let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
let char_align = super::textedit::char_align_param(p, C)?; let char_align = super::textedit::char_align_param(p, C)?;
let char_direction = super::textedit::char_direction_param(p, C)?;
let digits = super::textedit::digits_param(p, C)?; let digits = super::textedit::digits_param(p, C)?;
let font_axes = super::textedit::font_axes_param(p, C)?; let font_axes = super::textedit::font_axes_param(p, C)?;
let mojikumi = match p.get("mojikumi") { let mojikumi = match p.get("mojikumi") {
@@ -460,6 +464,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = char_align { if let Some(v) = char_align {
st.char_align = v; st.char_align = v;
} }
if let Some(v) = char_direction {
st.char_direction = v;
}
if let Some(v) = digits { if let Some(v) = digits {
st.digits = v; st.digits = v;
} }
+3 -2
View File
@@ -162,7 +162,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
None => None, None => None,
Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?), Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?),
}; };
let independent = bool_or(p, "independent", false); let independent = switch_or(p, "independent", false, "path.close")?;
s.edit("Close Path", |d, sel| { s.edit("Close Path", |d, sel| {
let path = path_mut(d, id)?; let path = path_mut(d, id)?;
let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?; let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
@@ -182,6 +182,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> { pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?); let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?);
check_switches(p, &["patterns"], "path.moveAnchors")?;
// Objects selected whole move as objects, and their pattern tiles move with them when // Objects selected whole move as objects, and their pattern tiles move with them when
// `patterns` (default: General › Transform Pattern Tiles) is on. // `patterns` (default: General › Transform Pattern Tiles) is on.
let whole = s.doc()?.selection.whole(); let whole = s.doc()?.selection.whole();
@@ -232,7 +233,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?; let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?;
let out = str_param(p, "which") != Some("in"); let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?); let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
let independent = bool_or(p, "independent", false); let independent = switch_or(p, "independent", false, "path.setHandle")?;
s.edit("Reshape", |d, _| { s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?; let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
+1 -1
View File
@@ -635,7 +635,7 @@ pub(crate) fn outlined_stroke(
blend: st.blend, blend: st.blend,
effects: st.effects.clone(), effects: st.effects.clone(),
overprint: st.overprint, overprint: st.overprint,
..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone()) ..vectorcraft_doc::appearance::FillLayer::new(st.paint.stroke_as_fill())
}; };
n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }.into(); n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }.into();
Some(n) Some(n)
+108 -31
View File
@@ -1,10 +1,11 @@
//! Placed documents (`file.place` of a VectorCraft document from a file, with `link`): one //! Placed documents (`file.place` of a VectorCraft document, a PDF or a `.ai` file from a file,
//! locked object showing an artboard of the document, a [`PlacedDocument`]. //! with `link`): one locked object showing an artboard of the document or a page of the PDF, a
//! [`PlacedDocument`].
//! //!
//! The object keeps the file's bytes (in the document's images, like image data) and the link; the //! The object keeps the file's bytes (in the document's images, like image data) and the link; the
//! art it shows is read from them the way placing the file without Link reads it //! art it shows is read from them the way placing the file without Link reads it
//! ([`document_art`]: the artboard's art, with the file's own linked images found), so Break Link //! ([`document_art`]: the artboard's or page's art, with the file's own linked images found), so
//! and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands //! Break Link and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands
//! ([`super::super::links`]) tell when the file changed and read it again. //! ([`super::super::links`]) tell when the file changed and read it again.
//! //!
//! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept //! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept
@@ -19,8 +20,9 @@ use std::sync::Arc;
use vectorcraft_doc::links::hash_bytes; use vectorcraft_doc::links::hash_bytes;
use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for}; use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for};
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PlacedDocument, PlacementOptions}; use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_geom::{Affine, Rect}; use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::CropTo;
use super::super::fileio; use super::super::fileio;
use super::super::*; use super::super::*;
@@ -29,7 +31,7 @@ use super::super::*;
pub(crate) const MAX_FILE: usize = 256 << 20; pub(crate) const MAX_FILE: usize = 256 << 20;
/// The format ids of VectorCraft documents. /// The format ids of VectorCraft documents.
const NATIVE: [&str; 2] = ["vectorcraft", "template"]; const NATIVE: [&str; 2] = ["vectorcraft", "template"];
/// The type a placed file's bytes are stored with. /// The type a placed VectorCraft document's bytes are stored with (a PDF's: its format's).
const MIME: &str = "application/json"; const MIME: &str = "application/json";
/// Is `name` with `bytes` a VectorCraft document? /// Is `name` with `bytes` a VectorCraft document?
@@ -37,7 +39,55 @@ pub(crate) fn is_native(name: &str, bytes: &[u8]) -> bool {
fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id)) fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id))
} }
/// An artboard of a VectorCraft document, read as art to place. /// Is `name` with `bytes` a PDF, or a `.ai` or `.ait` file read as PDF pages (not PostScript)?
pub(crate) fn is_pdf(name: &str, bytes: &[u8]) -> bool {
fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) && !vectorcraft_pdf::is_postscript(bytes)
}
/// The editing data of the `.ai` file `name` (`bytes`) when its PDF part is only a placeholder:
/// the document its pages are read from ([`super::pdf_art`]), with the files it links. `None` for
/// other files.
pub(crate) fn editing_data(name: &str, bytes: &[u8]) -> Option<Document> {
if !is_pdf(name, bytes) {
return None;
}
let read = fileio::page_document(bytes, 0, &fileio::LoadOptions::default());
match fileio::page_or_editing_data(name, bytes, None, read) {
Ok((doc, _, true)) => Some(doc),
_ => None,
}
}
/// The type the bytes of placed file `name` (`bytes`) are stored with: a PDF's format's, else
/// [`MIME`].
fn mime(name: &str, bytes: &[u8]) -> &'static str {
fileio::detect(name, bytes).filter(|_| is_pdf(name, bytes)).map_or(MIME, |f| f.mime)
}
/// The box of a PDF page placed linked for File › Place's `crop` (Bounding Box: the crop box,
/// the art's bounds being [`PlacedDocument::bounding`]).
pub(crate) fn page_box(crop: CropTo) -> PageBox {
match crop {
CropTo::Art => PageBox::Art,
CropTo::Trim => PageBox::Trim,
CropTo::Bleed => PageBox::Bleed,
CropTo::Media => PageBox::Media,
CropTo::Crop | CropTo::Bounding => PageBox::Crop,
}
}
/// The `crop` a PDF page placed linked showing `b` is read with.
fn crop_to(b: PageBox) -> CropTo {
match b {
PageBox::Crop => CropTo::Crop,
PageBox::Art => CropTo::Art,
PageBox::Trim => CropTo::Trim,
PageBox::Bleed => CropTo::Bleed,
PageBox::Media => CropTo::Media,
}
}
/// An artboard of a VectorCraft document, or a page of a PDF or `.ai` file, read as art to place.
pub(crate) struct DocumentArt { pub(crate) struct DocumentArt {
/// The document, its layers taken: the art's resources. /// The document, its layers taken: the art's resources.
pub doc: Document, pub doc: Document,
@@ -51,18 +101,33 @@ pub(crate) struct DocumentArt {
} }
/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when /// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when
/// there is one, so its linked images are found), clipped to the artboard unless `bounding`. /// there is one, so its linked images are found), clipped to the artboard unless `bounding`; or
pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<DocumentArt> { /// page `page` of the PDF or `.ai` file `name`, clipped to its box `page_box` unless `bounding`
/// ([`super::pdf_art`]).
pub(crate) fn document_art(
name: &str,
bytes: &[u8],
path: Option<&str>,
page: u32,
bounding: bool,
page_box: PageBox,
cmd: &str,
) -> Result<DocumentArt> {
let file = fileio::file_name(name); let file = fileio::file_name(name);
if !is_native(name, bytes) { let pdf = is_pdf(name, bytes);
return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document"))); if !pdf && !is_native(name, bytes) {
return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file")));
} }
if bytes.len() > MAX_FILE { if bytes.len() > MAX_FILE {
return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20))); return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20)));
} }
if pdf {
let o = fileio::LoadOptions { crop: crop_to(page_box), ..Default::default() };
return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd);
}
let mut l = fileio::load(name, bytes)?; let mut l = fileio::load(name, bytes)?;
// Its linked images show their files; its placed documents stay as they are. // Its linked images show their files; its placed documents stay as they are.
super::super::links::resolve(&mut l.doc, path, false); super::super::links::resolve(&mut l.doc, path, false, false);
l.doc.drop_edit_modes(); l.doc.drop_edit_modes();
let n = l.doc.artboards.len(); let n = l.doc.artboards.len();
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect); let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
@@ -87,7 +152,7 @@ fn group(nodes: Vec<Node>, clip: Option<Rect>) -> Node {
/// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents /// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents
/// ([`vectorcraft_doc::placed_document::set_loader`]). /// ([`vectorcraft_doc::placed_document::set_loader`]).
pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> { pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, "draw").ok()?; let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, p.page_box, "draw").ok()?;
Some((a.doc, group(a.nodes, a.clip), a.frame)) Some((a.doc, group(a.nodes, a.clip), a.frame))
} }
@@ -101,32 +166,36 @@ pub(crate) fn read_file_again(p: &PlacedDocument) -> Option<Vec<u8>> {
Some(bytes) Some(bytes)
} }
/// A VectorCraft document read for placing linked. /// A VectorCraft document, or a PDF or `.ai` file, read for placing linked.
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct Source { pub(crate) struct Source {
/// The file's bytes, with their preview. /// The file's bytes, with their preview.
pub blob: ImageBlob, pub blob: ImageBlob,
/// The blob's key ([`key_for`]). /// The blob's key ([`key_for`]).
pub key: String, pub key: String,
/// What is shown, in the file's coordinates: the artboard or the art's bounds. /// The box of a PDF page shown (unless the art's bounds are).
pub page_box: PageBox,
/// What is shown, in the file's coordinates: the artboard, the page's box or the art's bounds.
pub frame: Rect, pub frame: Rect,
pub warnings: Vec<String>, pub warnings: Vec<String>,
} }
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing /// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
/// artboard `page` (its art's bounds when `bounding`). /// artboard `page`, or the PDF or `.ai` file for showing page `page` clipped to its box
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<Source> { /// `page_box` (its art's bounds when `bounding`).
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str) -> Result<Source> {
// The same document placed the same way is read once (placing it again is free). // The same document placed the same way is read once (placing it again is free).
let key = key_for(&hash_bytes(bytes), page, bounding); let key = key_for(&hash_bytes(bytes), page, bounding, page_box);
if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() { if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() {
return Ok(s); return Ok(s);
} }
let a = document_art(name, bytes, path, page, bounding, cmd)?; let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?;
let mut blob = ImageBlob::new(MIME, bytes.to_vec()); let mut blob = ImageBlob::new(mime(name, bytes), bytes.to_vec());
let shown = PlacedDocument { let shown = PlacedDocument {
link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) }, link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) },
key: key.clone(), key: key.clone(),
bounding, bounding,
page_box,
width: a.frame.width(), width: a.frame.width(),
height: a.frame.height(), height: a.frame.height(),
xf: Affine::IDENTITY, xf: Affine::IDENTITY,
@@ -136,7 +205,7 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo
blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new); blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new);
// Drawing it needn't read it again. // Drawing it needn't read it again.
vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame)); vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame));
let s = Source { blob, key, frame: a.frame, warnings: a.warnings }; let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings };
let mut m = lock_memo(); let mut m = lock_memo();
if m.len() >= MEMO_SIZE { if m.len() >= MEMO_SIZE {
m.remove(0); m.remove(0);
@@ -203,6 +272,7 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool)
link, link,
key: s.key.clone(), key: s.key.clone(),
bounding, bounding,
page_box: s.page_box,
width: s.frame.width(), width: s.frame.width(),
height: s.frame.height(), height: s.frame.height(),
xf: Affine::translate((s.frame.x0, s.frame.y0)), xf: Affine::translate((s.frame.x0, s.frame.y0)),
@@ -211,32 +281,39 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool)
Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))) Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed)))
} }
/// `d` for output: the placed documents that keep only their previews get their files back, /// `d` for output: linked images and placed documents that keep only their previews get their
/// else (their files gone or changed) output as their previews, with a warning. /// files back, else output as their previews, with a warning.
pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) { pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) {
let (d, mut warnings) = super::super::links::full_linked_images(d, None);
let doc = match &d {
Cow::Borrowed(doc) => doc,
Cow::Owned(doc) => doc,
};
let mut need: Vec<PlacedDocument> = vec![]; let mut need: Vec<PlacedDocument> = vec![];
d.visit_placed(|_, p| { doc.visit_placed(|_, p| {
if d.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) { if doc.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) {
need.push(p.clone()); need.push(p.clone());
} }
}); });
if need.is_empty() { if need.is_empty() {
return (Cow::Borrowed(d), vec![]); return (d, warnings);
} }
let mut out = d.clone(); let mut out = match d {
Cow::Borrowed(doc) => doc.clone(),
Cow::Owned(doc) => doc,
};
let mut previews = BTreeSet::new(); let mut previews = BTreeSet::new();
for p in need { for p in need {
let Some(blob) = d.images.get(&p.key) else { continue }; let Some(blob) = out.images.get(&p.key) else { continue };
match vectorcraft_doc::placed_document::full_bytes(&p, blob) { match vectorcraft_doc::placed_document::full_bytes(&p, blob) {
Some(bytes) => { Some(bytes) => {
out.images.insert(p.key.clone(), ImageBlob { mime: MIME.into(), bytes, proxy: blob.proxy.clone() }); out.images.insert(p.key.clone(), ImageBlob { mime: mime(p.link.name(), &bytes).into(), bytes, proxy: blob.proxy.clone() });
} }
None => { None => {
previews.insert(p.key); previews.insert(p.key);
} }
} }
} }
let mut warnings = vec![];
if !previews.is_empty() { if !previews.is_empty() {
warnings.push(format!( warnings.push(format!(
"{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)", "{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)",
@@ -256,7 +333,7 @@ pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> {
let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?; let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?;
let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob) let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob)
.ok_or_else(|| bad(cmd, format!("{} can't be read, or changed since it was read: relink or update it first", p.link.path)))?; .ok_or_else(|| bad(cmd, format!("{} can't be read, or changed since it was read: relink or update it first", p.link.path)))?;
let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, cmd)?; let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, p.page_box, cmd)?;
let m = p.art_xf(a.frame).ok_or_else(|| bad(cmd, format!("object {} has no size", id.0)))?; let m = p.art_xf(a.frame).ok_or_else(|| bad(cmd, format!("object {} has no size", id.0)))?;
let mut nodes = a.nodes; let mut nodes = a.nodes;
super::adopt::adopt(d, &a.doc, &mut nodes); super::adopt::adopt(d, &a.doc, &mut nodes);
+99 -59
View File
@@ -13,7 +13,8 @@
//! preview ([`fileio::load`]). //! preview ([`fileio::load`]).
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page //! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art //! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
//! (`crop: "bounding"`). //! (`crop: "bounding"`). With Link, a PDF/.ai/.ait page placed from a file becomes a placed
//! document instead, as a VectorCraft document does (below).
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`, //! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
//! [`text`]). //! [`text`]).
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]). //! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
@@ -38,7 +39,7 @@ use std::sync::Arc;
use serde_json::{Value, json}; use serde_json::{Value, json};
use vectorcraft_color::Paint; use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling, SymbolBoxes}; use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, Scaling, SymbolBoxes};
use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_pdf::CropTo; use vectorcraft_pdf::CropTo;
@@ -62,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…", "Place…",
["File"], ["File"],
Some("Cmd+Shift+P"), Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
has_doc, has_doc,
place place
), ),
@@ -104,8 +105,8 @@ enum Art {
/// A text file's, an RTF file's or a Word document's text, placed as area type filling the /// A text file's, an RTF file's or a Word document's text, placed as area type filling the
/// natural box. /// natural box.
Text(text::Story), Text(text::Story),
/// A VectorCraft document placed linked: the file read, the link, and whether the art's /// A VectorCraft document or a PDF/.ai page placed linked: the file read, the link, and
/// bounds (not the artboard) are shown. /// whether the art's bounds (not the artboard or page) are shown.
Document(document::Source, LinkInfo, bool), Document(document::Source, LinkInfo, bool),
Vector { Vector {
/// The loaded document: the source of the art's resources. /// The loaded document: the source of the art's resources.
@@ -124,7 +125,7 @@ struct Loaded {
/// The art's natural box (what `at` centres and `rect` fits). /// The art's natural box (what `at` centres and `rect` fits).
natural: Rect, natural: Rect,
warnings: Vec<String>, warnings: Vec<String>,
/// The link to a raster image's file (for Link), when read from a path. /// The link to a raster image's file or a placed document's (for Link), when read from a path.
link: Option<LinkInfo>, link: Option<LinkInfo>,
/// The artboard a DXF drawing fitted to an artboard, or not centred, was laid out on: it lands /// The artboard a DXF drawing fitted to an artboard, or not centred, was laid out on: it lands
/// on the target artboard the same way. /// on the target artboard the same way.
@@ -138,8 +139,9 @@ pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
} }
/// The file `p` names, read into placeable art; a DXF drawing's Fit to Artboard fills `board` /// The file `p` names, read into placeable art; a DXF drawing's Fit to Artboard fills `board`
/// (unless the params name their own box). /// (unless the params name their own box). A PDF/.ai page read from a path is placed linked with
fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> { /// Link when `link_pdf` (false for the queries, which read it embedded).
fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loaded> {
let src = fileio::source(p, cmd)?; let src = fileio::source(p, cmd)?;
let name = fileio::file_name(src.name); let name = fileio::file_name(src.name);
// An Affinity file is recognised by its signature, whatever its name says. // An Affinity file is recognised by its signature, whatever its name says.
@@ -192,12 +194,12 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
if matches!(format.id, "vectorcraft" | "template") { if matches!(format.id, "vectorcraft" | "template") {
let page = page as u32; let page = page as u32;
if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) { if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) {
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, cmd)?; let s = document::source(src.name, &src.bytes, Some(path), page, !crop, PageBox::Crop, cmd)?;
let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) }; let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) };
let (natural, warnings) = (s.frame, s.warnings.clone()); let (natural, warnings) = (s.frame, s.warnings.clone());
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None }); return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
} }
let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, cmd)?; let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, PageBox::Crop, cmd)?;
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip }; let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None }); return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
} }
@@ -209,48 +211,39 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
let warnings = fileio::too_large_note(&img.blob.bytes).into_iter().collect(); let warnings = fileio::too_large_note(&img.blob.bytes).into_iter().collect();
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None }); return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
} }
if pdf {
// Linked: placed like a VectorCraft document, read again with the page's box only. A page
// asked for with a password or with its text as outlines is embedded.
let linked = link_pdf && bool_or(p, "link", true) && opts.password.is_none() && opts.text_as == vectorcraft_pdf::TextAs::Text;
if let Some(path) = src.path.filter(|_| linked) {
let page = page as u32;
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, document::page_box(opts.crop), cmd)?;
let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) };
let (natural, warnings) = (s.frame, s.warnings.clone());
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
}
let a = pdf_art(src.name, &src.bytes, src.path, page, opts, !crop, cmd)?;
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
}
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center); let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
let (mut doc, warnings, pdf) = match format.id { let (mut doc, warnings) = {
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's // Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
// bounds, below). A .ai saved without PDF compatibility is read from its editing data, as let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
// it opens, and placed as an EPS is: the art on artboard `page`. let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
"pdf" | "ai" | "ait" if pdf => { // An Affinity file whose native document couldn't be read is only its preview: it
let o = fileio::LoadOptions { crop: if crop { opts.crop } else { CropTo::Crop }, ..opts }; // must never silently stand in for the art.
let mut read = fileio::page_document(&src.bytes, page - 1, &o); if l.preview_only {
// Such a file can have more artboards than placeholder pages. return Err(bad(
if page > 1 cmd,
&& read.is_err() format!(
&& let Err(e) = fileio::page_document(&src.bytes, 0, &o) "{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing"
&& fileio::is_placeholder(&e) ),
{ ));
read = Err(e);
}
let (mut doc, warnings, alone) =
fileio::page_or_editing_data(src.name, &src.bytes, o.password.as_deref(), read).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
if alone {
// Linked images show their files, found from its folder.
super::links::resolve(&mut doc, src.path, false);
}
(doc, warnings, !alone)
}
_ => {
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
// An Affinity file whose native document couldn't be read is only its preview: it
// must never silently stand in for the art.
if l.preview_only {
return Err(bad(
cmd,
format!(
"{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing"
),
));
}
// Linked images show their files, found from its folder.
super::links::resolve(&mut l.doc, src.path, false);
(l.doc, l.warnings, false)
} }
// Linked images show their files, found from its folder.
super::links::resolve(&mut l.doc, src.path, false, false);
(l.doc, l.warnings)
}; };
doc.drop_edit_modes(); doc.drop_edit_modes();
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored); let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
@@ -264,19 +257,64 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
let frame = doc.artboards.first().map(|a| a.rect).filter(|_| framed); let frame = doc.artboards.first().map(|a| a.rect).filter(|_| framed);
(art_of(layer_art(&layers)), frame) (art_of(layer_art(&layers)), frame)
} else { } else {
// The page placed is a PDF import's only one.
let index = if pdf { 0 } else { page - 1 };
let n = doc.artboards.len(); let n = doc.artboards.len();
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?; let board = doc.artboards.get(page - 1).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
// A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard. // An EPS file's art is whatever lies on the artboard.
let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] }; (on_artboard(&symbols, &layers, board), crop.then_some(board))
(on_artboard(&symbols, pages, board), crop.then_some(board))
}; };
let natural = clip.or_else(|| shown_bounds(&symbols, &nodes)); let natural = clip.or_else(|| shown_bounds(&symbols, &nodes));
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board }) Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
} }
/// Page `page` (1-based) of the PDF/.ai/.ait file `src` (`bytes`, read from `path` when there is
/// one) as art to place: clipped to the box `opts.crop` names, or (`bounding`) the bounds of its
/// art; read with `opts.password`. Only the page placed is read. A .ai saved without PDF
/// compatibility is read from its editing data, as it opens, and placed as an EPS is: the art on
/// artboard `page`, its linked images found from its folder.
pub(crate) fn pdf_art(
src: &str,
bytes: &[u8],
path: Option<&str>,
page: usize,
opts: fileio::LoadOptions,
bounding: bool,
cmd: &str,
) -> Result<document::DocumentArt> {
let name = fileio::file_name(src);
let index = page.checked_sub(1).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))?;
let o = fileio::LoadOptions { crop: if bounding { CropTo::Crop } else { opts.crop }, ..opts };
let mut read = fileio::page_document(bytes, index, &o);
// Such a file can have more artboards than placeholder pages.
if page > 1
&& read.is_err()
&& let Err(e) = fileio::page_document(bytes, 0, &o)
&& fileio::is_placeholder(&e)
{
read = Err(e);
}
let (mut doc, warnings, alone) =
fileio::page_or_editing_data(src, bytes, o.password.as_deref(), read).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
if alone {
// Linked images show their files, found from its folder.
super::links::resolve(&mut doc, path, false, false);
}
doc.drop_edit_modes();
let layers = std::mem::take(&mut doc.layers);
let symbols = doc.symbol_boxes();
// The page placed is a PDF import's only one, with one layer; the editing data's art is
// whatever lies on the artboard.
let index = if alone { index } else { 0 };
let n = doc.artboards.len();
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
let pages = if alone { &layers[..] } else { layers.get(index..=index).unwrap_or_default() };
let nodes = on_artboard(&symbols, pages, board);
let clip = (!bounding).then_some(board);
let frame = clip.or_else(|| shown_bounds(&symbols, &nodes));
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
Ok(document::DocumentArt { doc, nodes, clip, frame, warnings })
}
/// The bounds of what `nodes` draw, as an export measures art /// The bounds of what `nodes` draw, as an export measures art
/// ([`vectorcraft_render::encode::drawn_bounds`]): hidden objects, guides and template layers /// ([`vectorcraft_render::encode::drawn_bounds`]): hidden objects, guides and template layers
/// left out, symbol instances measured by their art in `symbols`. /// left out, symbol instances measured by their art in `symbols`.
@@ -435,7 +473,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
(true, _) => return Err(bad(PLACE, "replace: select the one object to replace")), (true, _) => return Err(bad(PLACE, "replace: select the one object to replace")),
}; };
let target = board_at(&st.doc, at); let target = board_at(&st.doc, at);
let mut loaded = load(p, PLACE, target)?; let mut loaded = load(p, PLACE, target, true)?;
let old_node = old.and_then(|id| st.doc.node(id)); let old_node = old.and_then(|id| st.doc.node(id));
// Type reflows rather than scales: its frame takes the size it is placed at. // Type reflows rather than scales: its frame takes the size it is placed at.
let is_text = matches!(loaded.art, Art::Text(_)); let is_text = matches!(loaded.art, Art::Text(_));
@@ -548,7 +586,8 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
q[k] = v.clone(); q[k] = v.clone();
} }
} }
match load(&q, QUEUE, None) { // Read for its size and thumbnail: each click places it with file.place.
match load(&q, QUEUE, None, false) {
Ok(l) => { Ok(l) => {
let (w, h) = (l.natural.width(), l.natural.height()); let (w, h) = (l.natural.width(), l.natural.height());
let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h }); let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h });
@@ -606,7 +645,8 @@ fn thumbnail_png(l: Loaded, px: f64) -> Result<String> {
} }
fn info(_: &mut Session, p: &Value) -> Result<Value> { fn info(_: &mut Session, p: &Value) -> Result<Value> {
let mut l = load(p, "file.place.info", None)?; // A PDF/.ai page linked or not has the same size and art.
let mut l = load(p, "file.place.info", None, false)?;
let natural = l.natural; let natural = l.natural;
let mut out = json!({ let mut out = json!({
"name": l.name, "name": l.name,
+1 -1
View File
@@ -230,7 +230,7 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
None => s.prefs.plugins_folder.trim().to_string(), None => s.prefs.plugins_folder.trim().to_string(),
}; };
if folder.is_empty() { if folder.is_empty() {
return Err(bad(C, "no folder: pass `path` or set Preferences › Performance & Storage › Additional Plug-ins Folder")); return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder"));
} }
let report = load_folder(&folder); let report = load_folder(&folder);
if let Some(e) = report.get("error").and_then(Value::as_str) { if let Some(e) = report.get("error").and_then(Value::as_str) {
+6 -6
View File
@@ -49,7 +49,7 @@ pub struct PrefSpec {
pub kind: PrefKind, pub kind: PrefKind,
} }
/// Categories in Illustrator's order (Plug-ins & Scratch Disks is "Performance & Storage" here). /// Categories in Illustrator's order.
pub const PREF_CATEGORIES: &[&str] = &[ pub const PREF_CATEGORIES: &[&str] = &[
"General", "General",
"Selection & Anchor Display", "Selection & Anchor Display",
@@ -59,7 +59,7 @@ pub const PREF_CATEGORIES: &[&str] = &[
"Smart Guides", "Smart Guides",
"Slices", "Slices",
"Hyphenation", "Hyphenation",
"Performance & Storage", "Plug-ins & Scratch Disks",
"User Interface", "User Interface",
"Performance", "Performance",
"File Handling", "File Handling",
@@ -256,10 +256,10 @@ pub const PREF_SPECS: &[PrefSpec] = &[
]) ])
), ),
p!("hyphenationExceptions", "Hyphenation", "", "Exceptions (comma separated)", text), p!("hyphenationExceptions", "Hyphenation", "", "Exceptions (comma separated)", text),
// Performance & Storage // Plug-ins & Scratch Disks
p!("pluginsFolder", "Performance & Storage", "Plug-ins", "Additional Plug-ins Folder", text), p!("pluginsFolder", "Plug-ins & Scratch Disks", "Plug-ins", "Additional Plug-ins Folder", text),
p!("scratchPrimary", "Performance & Storage", "Scratch Disks", "Primary", text), p!("scratchPrimary", "Plug-ins & Scratch Disks", "Scratch Disks", "Primary", text),
p!("scratchSecondary", "Performance & Storage", "Scratch Disks", "Secondary", text), p!("scratchSecondary", "Plug-ins & Scratch Disks", "Scratch Disks", "Secondary", text),
// User Interface // User Interface
p!( p!(
"uiBrightness", "uiBrightness",
+4 -2
View File
@@ -192,8 +192,10 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
if !area.is_finite() { if !area.is_finite() {
return None; return None;
} }
// Whole pixels, and no larger than 64 Mpx. // Whole pixels, no larger than 64 Mpx, and no side longer than the renderer can address
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt()); // (a long thin object stays within the area but not within a side).
let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0;
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt()).min(side / b.width().max(b.height()).max(1.0));
if !(scale.is_finite() && scale > 0.0) { if !(scale.is_finite() && scale > 0.0) {
return None; return None;
} }
Loaded 100 of 355 files, more files were not shown because too many files have changed in this diff. Show more