Compare commits

..
Author SHA1 Message Date
bflatastic ae82ed6500 Docs: the ROADMAP records the community PRs landed on 2026-10-17 2026-10-17 14:30:04 +07:00
bflatastic 40dd19d17a i18n: Danish, Dutch, Indonesian and Simplified Chinese rows for the strings this batch adds 2026-10-17 14:09:46 +07:00
Marco Arnulfo 8148d024ea Transform: a percentage typed alone in W or H scales the size it shows (fixes #2401) 2026-10-17 13:52:06 +07:00
Marco Arnulfo 68c9d440ab Transform panel: the menu's Flip Horizontal and Flip Vertical keep the reference point (fixes #2398) 2026-10-17 13:52:06 +07:00
dotCooCoo bde7140396 Images: sampling and recoloring decode images past 512 MB, up to 2 GB
rgba_within checked an image's size as RGBA against the allowance it
was given (2 GB on the desktop), then decoded the image within the
image crate's default limit of 512 MB. An image whose decoded pixels
take more than 512 MB, such as an RGBA image of 11,586 × 11,586 pixels,
was refused although the canvas draws it: the Eyedropper picked up
nothing from it, and Edit Colors, Recolor Artwork, print separations
and the color adjustment effects in export and Expand Appearance left
its pixels as they were.

The decoder's limit is now the allowance that rgba_within is given.
An image decoded as RGBA is no longer copied before it is used.
2026-10-17 13:52:06 +07:00
dotCooCoo cdb0bbe315 Artboards: Duplicate Artboards, Copy and Cut take every selected artboard
With several artboards selected, the Artboards panel menu's Duplicate
Artboards copied only the active one, and Copy and Cut with the
Artboard tool took only the tool's active artboard.

artboard.duplicate, artboard.copy and artboard.cut now take `indices`,
and without `index` or `indices` they act on the artboards the
Artboard tool has selected. Duplicate Artboards passes the selected
artboards and selects the copies. Duplicate and Cut are one undo step
each, and Paste adds every copied artboard back, with its art, in one
undo step. The copies keep their places relative to each other,
together right of the last artboard. A cut fails when it names every
artboard.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:52:06 +07:00
bflatastic 32e22891d6 i18n(ja): a radial gradient kind reads 円形, as Radial Gradient does 2026-10-17 13:52:06 +07:00
dotCooCoo 69a90f6033 i18n: file filters, preset names and mark styles are translated in every complete language
Labels that the UI looks up through a variable or a context (#1259) had
rows only in the Bulgarian, Czech, Greek, Spanish, Finnish, French,
Polish, Brazilian Portuguese and Swedish catalogs. In German, Hungarian,
Italian, Japanese, Korean, Russian, Ukrainian, Vietnamese and both
Chinese, the UI showed most of them in English: the file dialogs' filter
names, the status bar's render and UI timings, New Document's preset
name, and the contributor tooltips with their plurals. The Printer mark
type list showed English "Roman" in each of those languages except
Hungarian, and in Japanese, Korean, Vietnamese and both Chinese it
showed the language's name for Japanese.

Those catalogs get the rows, each next to its related entry. German also
gets rows for the 3D gizmo's light, which the UI showed as "Hell"
(bright), and for Grain's Regular type, which the UI showed in English.
complete_catalogs_translate_render_site_labels requires the rows those
catalogs get and the light's row in every complete catalog, and checks
German's Regular row.

Docs: docs/localization-parity.md, docs/target-app-parity.md.
2026-10-17 13:52:06 +07:00
dotCooCoo 14de645441 Templates: Save as Template always writes a file with a template extension
file.saveAsTemplate kept a name whose extension named another format
and wrote the template into it: {path: "card.png"} wrote .vctemplate
JSON to card.png, and card.pdf typed in the desktop save panel got it
too. file.saveAs {format: "ait"} answered that document.export writes
other formats, but document.export does not write .ait either.

file.saveAsTemplate now replaces another format's extension (card.png
is saved as card.vctemplate, or card.ait for format: "ait") and adds
the extension to a name whose extension names no format. The desktop
panel adds the template's extension to any name that does not end in
a template extension (card.pdf.vctemplate), as Save As adds its own.
Save and Save As still refuse ait, and the error now names
file.saveAsTemplate.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:52:06 +07:00
dotCooCoo b9c3dd4519 Effects: effect.apply and effect.setParams refuse a color the effect can't draw
effect.apply and effect.setParams stored any string as the color of
Drop Shadow, Inner Glow, Outer Glow and Shift to Color. When the
effect could not read it, it drew its default color with no message,
so a typo such as #ff00 in the Drop Shadow dialog turned the shadow
black.

The commands now refuse such a color before they edit, with
"`color` must be a #rrggbb color", and the document and its undo
history stay as they were. Hex colors, the five named colors,
[r, g, b] and color objects still pass. The check runs in the
commands, so the app, command.batch steps and replayed journals get
it too. The effect dialog's OK shows the error and keeps the dialog
open, with Preview on or off. Saved documents open as before.

Docs: docs/mcp.md (effect params).
2026-10-17 13:52:06 +07:00
dotCooCoo b9d370aeff Render: every raster export warns about an undecodable image inside a pattern
A raster export warns that "an image couldn't be decoded and is
missing from the picture" when the count of failed decodes rose while
it drew. A pattern fill draws from a tile kept for each thread, and a
tile taken from that cache draws none of its art. Only the export that
made the tile counted the image, and later exports of the document gave
no warning although their pictures lacked the image too. The raster
kept for a blurred or filtered object had the same fault: the renderer
that draws pattern tiles keeps those rasters from one tile to the next,
so a tile drawn again took such an object from its raster and counted
nothing.

A kept tile and a kept effect raster now store how many decodes failed
on their thread while they were made, and each draw from them adds that
number to the count again.
2026-10-17 13:52:06 +07:00
dotCooCoo b9b681c748 Type: redefining a text style back to a default updates the text that uses it
A style captured from text left out the attributes that the text's
JSON skips at their defaults, such as the Hyphenation options, the
Composer, tab stops, Auto kerning and the character alignment.
Redefining a paragraph or character style from text set
back to the defaults therefore did not reach the text using the
style: a paragraph that had the old Hyphen Limit or Single-line
Composer from the style kept it. setAttrs dropped a default value it
was given in the same way.

New and Redefine now capture every attribute of the selected text,
and charStyle.setAttrs and paraStyle.setAttrs keep a default value.
When a style changes, text whose value equals the old definition's
takes the new value, a default included. Local overrides on text that
uses a style other than the Normal styles stay, and an attribute that
setAttrs leaves out still leaves the text's value alone. The Character
Styles and Paragraph Styles panels compare the attributes a style holds
with the same attributes of the selection. A paragraph style made from
type with Auto alignment no longer shows "+" on that type.

Two existing behaviors change. Applying a style made with New or
Redefine from text, without Clear Overrides, now also sets the
attributes that text had at their defaults, such as Auto kerning, no
OpenType features, the Every-line Composer, no tab stops and the
default Hyphenation options. Redefining [Normal Paragraph Style] or
[Normal Character Style] from text gives every paragraph or run that
uses it those attributes too, so local values of them on that text are
reset.

Docs: docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 78330cfa2f Actions: Batch keeps files named after data sets inside the destination folder
Batch over data sets names each output file after the document and the
data set, as the reference app's File + Data Set Name option does. It
joined that name onto the destination folder as it was, and a data set
name can hold any character. A data set named a/b failed to save, and
one named a/../../x wrote its file outside the destination folder. On
Windows, one named c\d also failed to save, and one named e:f left an
empty file.

The data set's name now goes through file_part, which Export for
Screens uses for its file name prefix and a format row's suffix, so
output file names have - in place of /, \ and : from a data set's name.
When two data sets then give the same name, compared without case, the
later ones get -2, -3 and so on, as artboard file names do, through a
distinct_names helper split out of unique_file_names. This covers the
folder destination and Override Save/Export.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 187a75ca3f Character: Small Caps, Superscript and Subscript buttons and menu items set the text
The Character panel's Small Caps, Superscript and Subscript buttons
were disabled, with "(on the roadmap)" tips, and always drawn off, and
the panel menu's items of the same names were disabled, although
text.setFormat and text.setRangeStyle already set smallCaps and
position. The reference app applies all three from the Character
panel menu.

The three buttons and menu items now turn small caps and the
superscript or subscript position on and off for the selected type, or
for the text the Type tool has selected, in Document Setup's
proportions. Turning Superscript on turns Subscript off, and turning
Subscript on turns Superscript off. They show as on when the first
selected character has the attribute, as the All Caps, Underline and
Strikethrough toggles do.

docs/mcp.md did not mention smallCaps and position; it now describes
them for text.setFormat and text.setRangeStyle.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 13df37f9d1 .ai import: a Drop Shadow with Darkness comes in with its Darkness
An object whose art style had a Drop Shadow with Darkness (a csrc
other than 0 in the shadow filter's dictionary) came in as its drawn
look, with a warning that its appearance isn't editable yet. With a
Color shadow instead, the same object comes in as itself with its
fill, stroke and shadow.

The reader now reads such a shadow as Drop Shadow with shade
"darkness" and darkness set to the filter's dark value, a percentage
kept within 0 to 100, or 100 when the value is missing or not a
finite number. The shadow's other keys are read as for a shadow with Color,
and the object comes in as itself wherever it would with a shadow with
Color. A shadow with Color comes in with the same params as before.
The import fuzz's .ai tokens now include csrc and dark.

Docs: docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 8f71c94473 Type: a change of paint, baseline shift or rotation on one character keeps the font's kerning
Each style run was shaped on its own, so restyling one character, even
only its fill, dropped the font's kerning pairs at the new run
boundaries. A fill change on the V of 40 pt "AVAV" moved it 0.56 pt and
moved the characters after it 1.12 pt. Manual kerning on a character
also dropped the font's kerning with the character before it.

Adjacent runs that differ only in fill, stroke, underline,
strikethrough, overprint, character style name, leading, baseline
shift, rotation, Character Alignment, kerning, or tracking (as long as
both values leave standard ligatures on or both turn them off) are now
shaped together, and each glyph takes those attributes from its own
run. The font's kerning pairs apply after characters with automatic
kerning: a manual value replaces the pair after its character, and the
pair before it stays, except before a character that All Caps or Small
Caps sets as two letters (ß as SS). A change of font, size, scale or
OpenType features still shapes the characters apart, and a ligature or
a mark still does not join characters in different styles.

Docs: docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 8511d0321b SVG export: justified and Mojikumi lines keep their letter spacing in live text
SVG export wrote each style run of a line of live text as one <tspan>
and positioned the text again only after tabs and, on justified lines,
after word spaces. A viewer then spaced the characters inside the
<tspan> with the font's own advances, so it lost the letter gaps of a
Justify All line without word spaces, the gaps between the characters
of a justified Japanese line and the spacing that a Mojikumi Set
changes. On justified lines, the textLength of horizontally scaled type
also took in the justification space after it, so a viewer stretched
the glyphs into that space.

On a line that is justified or set with a Mojikumi Set, text_lines now
lays the text out again as point type without the Mojikumi Set to get
the advance a viewer gives each character. Each character that the
layout draws away from where a viewer would draw it starts a new
positioned <tspan>, and textLength and line anchors use the viewer's
widths. The last character of a horizontally scaled <tspan> takes its
width without the font's kerning against the next character. Other
lines, and lines written with Fewer <tspan> Elements, are written as
before.

Docs: docs/target-app-parity.md.
2026-10-17 13:52:05 +07:00
dotCooCoo 1e87e85ab7 i18n: Simplified Chinese translates status-bar and error messages
The Simplified Chinese catalog had 20 `@msg` rows, against 714 to 718
in each catalog listed in COMPLETE_MESSAGES, so with the interface in
Simplified Chinese most status-bar and error messages showed in
English.

- zh-hans.tsv gains the 694 missing `@msg` rows in one block with a
  header comment on its conventions: no final 。 unless the English
  ends with a period, a space between Chinese and adjacent Latin
  text, numbers or placeholders, and 、 between list items. The rows
  use the catalog's existing terms (画板, 图稿, 编组, 描边, 色板, 实时上色,
  图像描摹, 增效工具 ...).
- zh-hans joins COMPLETE_MESSAGES, so
  complete_languages_translate_every_message and the narrower message
  tests fail when a zh-hans message row is missing, and a new message
  needs a zh-hans row.
- The registry comment, docs/development.md (which also gains Greek,
  already in COMPLETE_MESSAGES), the zh-hans row of
  docs/localization-parity.md, and the interface-languages line of
  docs/target-app-parity.md (which also gains Czech, Greek, Hungarian,
  Swedish and Finnish) list Simplified Chinese among the languages
  that translate messages.
2026-10-17 13:52:05 +07:00
dotCooCoo c5d2297ef1 Docs: type-parity and the roadmap list the Hyphenation options as done
docs/type-parity.md and the Set type row of docs/roadmap.md still
listed the Hyphenation… options as missing, although the Hyphenation
dialog (#2310) sets them for each paragraph.

The type-parity row is now partial and names Words Longer Than, After
First, Before Last, Hyphen Limit, Hyphenation Zone and Hyphenate
Capitalized Words. Justification… stays missing. The Set type row of
roadmap.md lists only the justification options as missing. Its score
is unchanged. docs/target-app-parity.md and docs/gaps.md are left as
they are.
2026-10-17 13:52:05 +07:00
bflatastic bf785840e5 Docs: file-format-parity keeps one PNG/TIFF row with both notes 2026-10-17 13:52:05 +07:00
dotCooCoo 3fc78187ec TIFF: planar grayscale files with extra channels, and planar CMYK files, open and place
TIFFs that store each channel as its own plane (PlanarConfiguration 2)
did not open, place or relink when they were CMYK or grayscale with
extra channels: the image decoder refuses planar CMYK, and the readers
for CMYK inks and for extra samples returned nothing for planar files.

The two readers now read every plane through the tiff crate and put
each pixel's samples together. A planar CMYK TIFF keeps its ink
amounts and is stored as a CMYK TIFF with each pixel's inks together,
at the file's resolution. Grayscale TIFFs with an alpha channel or
other extra channels, and CMYK TIFFs with alpha, read as their chunky
versions do.

A TIFF whose header claims more strips or tiles than the tiff decoder
can count is refused as too large to draw. The decoder multiplies
those counts without a check, which panics in builds with overflow
checks.

Part of #1466.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
dotCooCoo 5a3e833ec8 Type: a click in a Devanagari syllable goes to its nearer end, and shaping is tested
A syllable drawn with several glyphs that share one cluster, such as a
pre-base i matra and its consonant (कि), had a caret stop at the edge
of each glyph. A click just right of the matra, in the left half of
the syllable, put the caret after the syllable. byte_in_line now takes
one extent for each cluster, and type on a path compares the click
with the middle of the cluster, from the start of its first glyph to
the end of its last.

New tests with an installed OpenType Devanagari font check that क्ष
forms one conjunct glyph, that र्क draws the reph after its base, that
कि draws the matra before its consonant, that Ka with a nukta matches
the precomposed क़, and that each syllable is one cluster with the
arrow keys stopping at its ends. They print SKIPPED when no such font
is installed.

Docs: docs/localization-parity.md (Hindi row), docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
dotCooCoo 5bdd9d539f Variables: Import Data Sets… reads tab-separated files and files without a name column
dataset.import read only commas between cells and refused a file whose
first header cell wasn't `name`. A value that didn't match its
variable's kind, and a repeated data set name, were reported with the
row's record number, which is not the file line after a blank line or a
quoted line break. A data set name over 255 characters gave no line.

- When the header row, read with tabs between cells, has a tab and no
  comma outside quotes, tabs separate the cells. A quoted cell may then
  hold tabs and commas.
- Without a `name` column every header cell is a variable, and the rows
  take the first free names Data Set 1, Data Set 2 and so on, as Capture
  Data Set and blank name cells do. A first header cell `name`, in any
  case, still holds the data set names.
- Value errors, a repeated data set name and a name over 255 characters
  give the file line the row starts on.
- The Variables panel's Import Data Sets… dialog also lists .tsv files.
- import_fuzz.rs imports random text, random bytes and mutated exports,
  comma- and tab-separated, into a document with bound text, visibility
  and graph variables, selects up to three of the imported data sets in
  turn, and exports every data set.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
dotCooCoo a285efda46 Print: Print, Print Preview and Print Setup take a print preset by name
file.print, print.preview and print.setup took print settings only as
settings, so an agent had to read a preset with print.presets.list and
copy its settings into the call. They now take preset, the name of
[Default] or of a saved print preset in any case. The preset's settings
are used instead of the document's, settings applies over them, and
print.setup saves them with the document as one undo step. An unknown
name is a parameter error that lists the presets. The app's file.print
with params passes preset on to the engine.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
dotCooCoo d5e44b321e Swatches: Alt-click on a swatch recolors the selected gradient stop
With a gradient stop selected, an Alt-click on a color swatch painted
the inactive Fill or Stroke, as it does with no stop selected. The
reference app's Default keyboard shortcuts page lists "Apply swatch
color to active (or selected) color stop: Alt-click swatch in the
Swatches panel" for the Gradient panel.

Now, when the active Fill or Stroke is a linear or radial gradient with
a selected stop, an Alt-click on a color swatch or a tint swatch
recolors that stop in one undo step, and the stop stays selected. The
stop changes as a drop of the same swatch on the Gradient panel's ramp
changes it (drop_color and dropped_link in panels/gradient.rs): it
becomes fully opaque, and a global or spot color or a tint stays
linked, so editing the swatch recolors the stop.
With a freeform gradient, the selected point takes the color through
paint.freeform.setPoint. With no stop or point selected, Alt-click
paints the inactive Fill or Stroke as before. The Fill and Stroke chip
popovers show the same swatches and behave the same.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
bflatastic 6902e1ab3f Dialogs: drop the slider wrappers nothing calls since the named ones 2026-10-17 13:49:49 +07:00
dotCooCoo f0bb2760c9 Accessibility: buttons, toggles, checkboxes and sliders have names for screen readers
The controls that the shared widgets draw had no name in the
accessibility tree: icon, flat, primary, secondary and split buttons,
chips, toggles, checkboxes, radio buttons, tabs, sliders, the angle
dial, the Reference Point's squares, the spinner and preset arrows and
the searchable combo boxes. Screen readers announced each clickable one
as an unnamed element of unknown role, and a disabled icon button was
not in the tree. The Color Guide's library button, the Color Themes
Save button and the status bar's artboard buttons also had no name.

Each helper in widgets.rs except color_field now gives its response a
role, a name and its state. The name is the text, label or translated
tool tip the helper shows. The spinner, the preset arrow, the angle
dial, the harmony wheel and the Reference Point's squares, which show
none, are named "Step up or down", "Presets", "Angle", "Color wheel"
and Top Left to Bottom Right. A toggle, checkbox, radio button, tab or
Reference Point square reports whether it is on, an icon button reports
that it is pressed while it is lit, the angle dial reports its angle,
and a searchable combo box reports the text it shows. A checkbox or
radio button drawn without text is named by the label beside it. The
Tools panel's tool buttons, flyout rows and floating strips are named
by their tools' tool tips, with the active tool pressed. The title
bar's column toggle, the tear-off bars, a floating strip's close box
and the Color, Gradient and None buttons are named by their tool tips.

The status bar's First, Previous, Next and Last Artboard buttons now
have tool tips, and the Fill and Stroke chips' tool tips are
translated. The six new strings (the four artboard tips, "Color wheel"
and "Step up or down") have rows in every catalog.

split_button, chip_button, color_slider, channel_slider and the dialog
form's slider_field and slider_rail keep their arguments. New _named
variants beside them take the arrow's tool tip, the chip's tool tip or
the slider's name, and every caller in the tree uses them.

Docs: docs/target-app-parity.md.
2026-10-17 13:49:49 +07:00
bflatastic a71f8eb56a Persistence: startup reads at most 256 MB of stored colour profiles (#2174) 2026-10-17 13:49:48 +07:00
vangel76andClaude Opus 5.5 a8e45f0e2d Persistence: saved PDF presets and loaded ICC profiles survive a restart (#2168, #2174)
PDF presets are kept one file each in a PDF Presets folder, ICC profiles as copies in a Color Profiles folder, next to the preferences; the desktop app and vectorcraft-cli use them. Adds color.removeProfile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:49:48 +07:00
bflatastic 51f8582c25 MCP: a cancelled export keeps no partly drawn tile or raster, and its message is translated (#2165) 2026-10-17 13:49:48 +07:00
vangel76andClaude Opus 5.5 9122ed0a7e MCP: cancel long exports, answer while one runs, report progress (#2165, #2171, #2172)
The MCP server read one line, ran it and answered it before reading the next,
so notifications/cancelled was never seen during a call and every request
(even ping) waited behind a running export.

- vectorcraft_doc::cancel: a thread-local cancel token and progress sink that
  long work polls. Checkpoints: PDF pages, PDF flattening, geometry bake,
  raster-effect flattening, drawing objects, raster artboards, and before any
  file is written. A cancelled command returns EngineError::Cancelled and
  changes nothing.
- Server::serve reads on its own thread: notifications/cancelled drops a
  queued request or stops the running one (no reply, as the MCP spec says),
  engine-free requests (ping, the list methods, prompts/get) are answered at
  once, a request that would wait behind a call running for 2 s is refused
  with -32003 naming that call, and _meta.progressToken gets
  notifications/progress (page n of m). Queue and output are bounded.
- The remote backend stops waiting for the app when the call is cancelled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:49:48 +07:00
vangel76andClaude Opus 5.5 6920adf092 PDF export: report RGB colours and images in a CMYK document (#2162)
A CMYK document holding RGB colours or images separates them into CMYK on export, and dark ones come out as rich blacks. The export report now says how many there are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:49:48 +07:00
vangel76andClaude Opus 5.5 1eb9bea82a Inspect: scope document.inspect / doc_inspect by ids, kind, limit and offset (#2167)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:49:48 +07:00
bflatastic d55b4f77cf AI import: cite the published format specification by its title 2026-10-17 13:49:48 +07:00
Roman Vasilyev 8b8c164e3f CI: simplify boolean guards and translate EPS mask errors 2026-10-17 13:48:06 +07:00
Roman Vasilyev 4ed644d98d EPS: preserve editable Illustrator art and filter raster page comparison 2026-10-17 13:48:05 +07:00
BFlat 26644850fc Merge pull request #2484 from vangel76/fix/2332-corner-radius-to-neighbour
Live Corners: a corner rounds until its cut reaches its neighbours (#2332)
2026-10-17 13:45:25 +07:00
BFlat 8735dbc6fd Merge pull request #2237 from EuanTop/fix/2208-native-font-name-fallback
Fix native font labels when only legacy names are localized
2026-10-17 13:45:10 +07:00
BFlat c266083acd Merge pull request #2510 from vangel76/fix/2426-innerglow-pattern
Render: Inner Glow on a pattern fill draws along the edges, not at the top left (#2426)
2026-10-17 13:44:51 +07:00
BFlat a3dc3ca68e Merge pull request #2482 from vangel76/fix/2432-dimension-shift-preview
Dimension tool: Shift preview, snap indicator, clicks snap onto lines and anchors (#2432, #2516)
2026-10-17 13:44:37 +07:00
BFlat d82eafce7a Merge pull request #2487 from vangel76/fix/2457-tab-line-width
Type: a tab takes its width when area type breaks and justifies its lines (#2457)
2026-10-17 13:44:23 +07:00
BFlat f3ff11ff45 Merge pull request #2478 from vangel76/fix/2413-text-create-bounds
Type: text.create and text.createInPath return the new type's bounds (#2413)
2026-10-17 13:44:11 +07:00
BFlat cb33850e9c Merge pull request #2475 from vangel76/fix/2343-zigzag-smooth-wave
Effects: Zig Zag Smooth points draw a continuous wave (#2343)
2026-10-17 13:43:59 +07:00
BFlat a1372d498a Merge pull request #2486 from vangel76/fix/2451-transform-gradient
Effects: a Transform effect on a group paints each copy with its own gradient (#2451)
2026-10-17 13:43:48 +07:00
BFlat b9cc13beff Merge pull request #2477 from vangel76/fix/2476-effects-craft-fonts-skip
Effects: skip type tests when built without craft-fonts (#2476)
2026-10-17 13:43:37 +07:00
BFlat b5cadb9412 Merge pull request #2640 from storytold/pr/2585-quick-trace-docs
docs: quick Image Trace presets trace at once; the Image Trace button opens the controls (#2585)
2026-10-17 13:43:17 +07:00
BFlat 981f28469e Merge pull request #2566 from dotCooCoo/fix/artboard-commands-bad-index
Artboards: commands given an index that names no artboard fail instead of editing another one
2026-10-17 13:42:03 +07:00
BFlat 0072594442 Merge pull request #2568 from Silvertree2010/fix/h2255-graph-data-csv-does-not-round-tr
Quote graph data CSV labels so they round-trip through graph.setData
2026-10-17 13:41:52 +07:00
BFlat d27a356007 Merge pull request #2579 from dotCooCoo/fix/raster-effects-ppi-on-load
Document Raster Effects Settings: a file's resolution outside 1 to 2400 ppi opens in range
2026-10-17 13:41:40 +07:00
BFlat 02a0ad2541 Merge pull request #2599 from dotCooCoo/fix/appearance-inline-text-typing
Appearance: the inline effect editor's text fields keep what is typed
2026-10-17 13:41:29 +07:00
BFlat 71eae2559d Merge pull request #2601 from dotCooCoo/feat/space-shift-rotate-view
Rotate View: a Space+Shift drag rotates the view from any tool
2026-10-17 13:41:18 +07:00
BFlat c61fd7085f Merge pull request #2583 from dotCooCoo/fix/presentation-mode-non-editable
View: in Presentation Mode no key but ←, → and Escape acts, and no context menu opens
2026-10-17 13:41:06 +07:00
BFlat e9c6c53b2a Merge pull request #2613 from dotCooCoo/feat/links-shift-range
Links: Shift-click selects a range of links
2026-10-17 13:40:56 +07:00
BFlat 8aef393aab Merge pull request #2612 from dotCooCoo/feat/graphic-styles-shift-range
Graphic Styles: Shift-click selects a range of styles
2026-10-17 13:40:41 +07:00
BFlat c94f93ddc0 Merge pull request #2618 from dotCooCoo/fix/library-panel-click-anchor
Libraries: a plain click sets where a Shift-click range starts
2026-10-17 13:40:26 +07:00
BFlat 8b8607db8c Merge pull request #2620 from dotCooCoo/fix/issue-2384
Part of #2384: imported PDF text keeps its place after wide gaps between words
2026-10-17 13:40:11 +07:00
BFlat c600673648 Merge pull request #2622 from odgrim/fix/intertwine-expand-2540
Fix #2540: Object → Expand expands Intertwine groups
2026-10-17 13:39:54 +07:00
BFlat 63fc56f327 Merge pull request #2511 from vangel76/feat/2412-export-rect
Export: raster document.export takes a page rectangle (#2412)
2026-10-17 13:39:39 +07:00
BFlat da90552ee5 Merge pull request #2495 from vangel76/fix/2494-gradient-chip-angle
Gradient chips: follow the angle and the gradient type (#2494)
2026-10-17 13:39:24 +07:00
BFlat d60f3b12ff Merge pull request #2499 from vangel76/fix/2421-floating-panel-opaque
Panels: floating panel groups paint an opaque background (#2421)
2026-10-17 13:39:11 +07:00
BFlat 92fa9cbab2 Merge pull request #2498 from vangel76/fix/2383-merge-selected-sublayer
Layers: Merge Selected with a layer and its sublayer highlighted merges into the last top-level layer (#2383)
2026-10-17 13:38:47 +07:00
BFlat 8d185babb4 Merge pull request #2514 from vangel76/fix/2415-overprint-same-colour
Overprint Preview: a colour overprinting itself stays that colour (#2415)
2026-10-17 13:38:34 +07:00
BFlat 0e3ce7744b Merge pull request #2503 from vangel76/fix/2493-generic-cmyk-black-roundtrip
Color: Generic CMYK black round-trips through hex (#2493)
2026-10-17 13:38:22 +07:00
BFlat d5a3422dcd Merge pull request #2517 from vangel76/fix/2323-overflow-marker
Type: selected overflowing type shows its red out port with any tool (#2323)
2026-10-17 13:38:07 +07:00
BFlat 1d19d84493 Merge pull request #2518 from vangel76/fix/2217-kerning-caret
Character: the Kerning field at a caret kerns the pair, not the whole text (#2217)
2026-10-17 13:37:54 +07:00
BFlat 1686e6748e Merge pull request #2519 from vangel76/fix/2221-png-srgb
PNG export: an sRGB chunk marks the colours as sRGB (#2221)
2026-10-17 13:37:42 +07:00
BFlat d67290253d Merge pull request #2502 from vangel76/fix/2450-wayland-cursor-theme
Wayland: pointer returns over panels and menus after a tool cursor (#2450)
2026-10-17 13:37:17 +07:00
BFlat 4064ef8656 Merge pull request #2570 from dotCooCoo/fix/zoom-hint-test-language-race
Tests: a language one ui-egui test sets no longer translates the tests running beside it
2026-10-17 13:36:59 +07:00
BFlat ffd343f058 Merge pull request #2605 from dotCooCoo/fix/cli-test-stale-temp-folders
Tests: CLI tests do not fail on folders an earlier run left behind
2026-10-17 13:36:44 +07:00
BFlat 938fb5bce6 Merge pull request #2463 from dotCooCoo/fix/action-batch-wasm-unused-import
Lint: action_batch.rs compiles without an unused import on wasm32
2026-10-17 13:36:26 +07:00
BFlat f5241fabd0 Merge pull request #2447 from Silvertree2010/fix/h2170-xml-id-round-trip-changes-names
Escape XML ID first characters that cannot start an XML name
2026-10-17 13:36:05 +07:00
BFlat b81d8ccf6c Merge pull request #2470 from Silvertree2010/fix/h2224-color-from-hex-accepts-as-a-hex
Reject '+' and other non-hex characters in Color::from_hex
2026-10-17 13:35:54 +07:00
BFlat ce45d2a79c Merge pull request #2610 from Silvertree2010/fix/h2365-graph-axis-labels-saturate-at-92
Fix graph axis labels saturating beyond the i64 range
2026-10-17 13:35:44 +07:00
BFlat 2469a77ad4 Merge pull request #2440 from dotCooCoo/fix/raster-effect-export-huge-bounds
PDF export: Add Around is clamped when a file opens, and art too large for f64 gets no effect image
2026-10-17 13:35:34 +07:00
BFlat fd28438c57 Merge pull request #2407 from dotCooCoo/fix/crop-image-trim-size-check
Crop Image: trim checks the image's RGBA size before decoding it
2026-10-17 13:35:19 +07:00
BFlat 27b5eb7100 Merge pull request #2468 from dotCooCoo/fix/rgba-within-size-overflow
Images: sampling and recoloring check an image's RGBA size without overflowing
2026-10-17 13:35:02 +07:00
BFlat 38314db4ba Merge pull request #2604 from dotCooCoo/fix/decode-failure-count-race
Render: an export no longer warns about images another thread could not decode
2026-10-17 13:34:44 +07:00
BFlat 1aaa81f56b Merge pull request #2442 from dotCooCoo/fix/render-image-rgba-size-check
Render: check an image's RGBA size before decoding it
2026-10-17 13:34:34 +07:00
bflatastic d5561d6e02 Live Corners: drop the single-corner radius limits nothing uses since #2332 2026-10-17 13:20:26 +07:00
EuanTop 0746519517 Fix localized legacy font family name fallback 2026-10-17 13:20:26 +07:00
bflatastic 8fead43ed2 Tests: only the new Inner Glow test lays a background under the pattern (#2426) 2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 4dcd0b6caa Dimension tool: show where a Linear click snaps before the click (#2516)
The snap ring on an anchor (or a dot on a line) and the rubber band now follow the snapped point, so both measured points visibly land on anchors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 285a3da81a Type: centre and right aligned lines set their tabs as if they started at the left (#2457)
A tab's width depends on where it falls from the tab origin. Line breaking and the line width measure it from the left edge, but a centred or right aligned line is placed from its own start, so its tabs stopped elsewhere. The line is now laid out with its tabs measured from the left and then aligned as a whole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Sonnet 5.5 4f30e52368 Type: text.create and text.createInPath return the new type's bounds (#2413)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 d3751cc07b Effects: Zig Zag Smooth points draw a continuous wave (#2343)
Smooth Zig Zag ran the ridge points through a Catmull-Rom spline. Its end tangents slant across the path, and where the ridges are taller than about twice their spacing the sharp-turn floor of turn_tangent lengthens the handles, so the result read as straight diagonals with rounded tips. Each peak and valley is now a smooth point whose handles run along the path, a third of the way to the neighbouring ridge, so every span follows a half cosine through the path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 7cd6b5d983 Effects: Expand Appearance and export give each Transform copy of a gradient path its own gradient (#2490)
A path with a Transform effect that makes copies and a gradient was still merged into one path by bake_leaf and expand_leaf, so the gradient spanned every copy. Route it through reshape / expand_outlined like the renderer does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 8875b3fbca Live Corners: a corner rounds until its cut reaches its neighbours (#2332)
A corner's radius was capped at half its shorter side whatever its neighbours did, so one corner of a 100 pt square rounded alone stopped at 50 pt. Each corner now rounds until its cut reaches the cuts its neighbours ask for: the whole side when the neighbour is square, halfway when both ask for more. The corner widget drag, the red limit outline and CSS border-radius export follow the same limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 2108c3cc99 Render: a glow's pixmap is drawn without the paint transform a pattern fill left (#2426)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 848e7a296a Dimension tool: a click near a line snaps the measured point onto the line (#2432)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 101fd55fa0 Type: a tab takes its width when area type breaks and justifies its lines (#2457)
Line breaking, the line's natural width (and so justification) and the composer counted a tab as its glyph's width, a space, while it is set up to its next tab stop. A paragraph starting with a tab, as in the report, filled a justified line as if the tab were one space, and the line ran past the frame. break_line and the line width now measure tabs with tab_advance, and a paragraph with a tab is broken line by line, since the composer works with fixed glyph widths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 6939a481a4 Dimension tool: Shift shows the constrained line while picking the second point (#2432)
The Shift click already snapped the second point to 45-degree steps, but the dashed line from the first point followed the raw pointer, so the constraint looked absent until the click.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:26 +07:00
vangel76andClaude Opus 5.5 b28e8490c2 Effects: skip type tests when built without craft-fonts (#2476)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:25 +07:00
andeecollardandClaude Opus 5.5 b7018a3370 docs: quick Image Trace presets trace at once; the Image Trace button opens the controls
The UI parity row (and G12 in gaps.md, M12 in roadmap.md) said quick Image
Trace presets reveal the editable controls. Since baadbe2c6 a quick trace
leaves the panel as it was (test a_quick_trace_leaves_the_panel_as_it_was)
and the Image Trace button in the Control bar or Properties opens the
controls. That matches the public documentation: choosing a preset traces
the image with it straight away, and the panel is opened with the Image
Trace button in the Control panel or the Properties panel's Image Trace
section
(https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html).

Reworded the three rows, cited the URL, added revision-history entries and
status-line notes. Wording only; scores, statuses and hours unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
2026-10-17 13:20:25 +07:00
vangel76andClaude Opus 5.5 a6012d86e8 Effects: a path with its own Transform effect paints each copy with its own gradient (#2451)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:25 +07:00
vangel76andClaude Opus 5.5 27230233cc Effects: a Transform effect on a group paints each copy with its own gradient (#2451)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:25 +07:00
dotCooCoo ee61583606 Artboards: commands given an index that names no artboard fail instead of editing another one
artboard.setProps and artboard.fitToSelection read an `index` that is
negative, fractional, a string or null as 0 and edited the first
artboard, and artboard.setProps edited the first artboard when the
call named no `index`. artboard.duplicate, artboard.copy and
artboard.cut took the Artboard tool's artboard for such an `index`.
artboard.reorder failed for it with "missing index".

These commands now read `index` as artboard.delete, artboard.move and
artboard.fitToArt do, and fail with "no such artboard" when it names
no artboard; the document and the clipboard stay as they were.
artboard.setProps without `index` fails with "missing index". Without
`index`, artboard.fitToSelection still fits the first artboard and the
copy commands still take the Artboard tool's artboard.

Docs: docs/mcp.md.
2026-10-17 13:20:23 +07:00
NetterSilverandClaude Haiku 5.5 92647fa5b6 Quote graph data CSV labels so they round-trip through graph.setData
Found while testing crates/doc/src/graph.rs:429 (GraphSpec::to_csv) and crates/engine/src/cmd/graph.rs:85 (parse_csv)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:23 +07:00
dotCooCoo 1e3886c053 Document Raster Effects Settings: a file's resolution outside 1 to 2400 ppi opens in range
document.rasterEffectsSettings accepts a resolution from 1 to 2,400
ppi, but a file's raster_effects_ppi opened as it was saved. With 0,
-5 or 5000 in the file, object.rasterize without a ppi param failed
with "ppi must be between 1 and 2400", and document.rasterEffectsSettings
and document.info reported the stored value. With 0 or -5, EPS and
PDF export wrote a freeform gradient as a 1 x 1 px image, and EMF
export wrote every gradient as one. A saved New Document preset in the
preferences kept such a value too.

A file's or a saved preset's resolution outside 1 to 2,400 ppi is now
read as the nearer end of that range. Documents with a resolution in
range open as before.

Docs: docs/mcp.md (resolution, rasterEffectsPpi).
2026-10-17 13:20:23 +07:00
dotCooCoo 5eea7e4f66 Appearance: the inline effect editor's text fields keep what is typed
The Appearance panel's inline effect editor drew each text parameter
(such as Drop Shadow's color and Revolve's edge) as a text field over
a copy of the stored value made again in every frame. A character
typed in one frame was gone in the next, so the field changed a value
only when its new text and the Enter press arrived in the same frame,
as with a paste.

The field is now the panels' shared exact_text_field, which keeps the
text typed while it has the focus, as the number fields beside it do.
Enter or a click elsewhere sends the value with effect.setParams as
one undo step, and Escape drops it.
2026-10-17 13:20:23 +07:00
dotCooCoo 2e9870b1d0 Rotate View: a Space+Shift drag rotates the view from any tool
A drag with Space and Shift held panned the view, as a Space drag does.

With Space and Shift held and Cmd up, a drag on the canvas now turns
the view about the canvas center, as a Rotate View tool drag does,
whatever the tool. Shift does not round the angle to 15° as it does
with the Rotate View tool, and the drag keeps turning the view when
Shift is let go. Space alone still pans, and Cmd+Space still zooms
with or without Shift. The pointer shows the rotate cursor while Space
and Shift are held without Cmd, and during the drag. The Rotate View
tool also shows it now, where it showed the arrow.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo 951f7c2842 View: in Presentation Mode no key but ←, → and Escape acts, and no context menu opens
In Presentation Mode the canvas showed the active tool's cursor, a
right click selected the object under the pointer and opened the
context menu, and every key other than the arrows and Escape worked as
in Normal Screen Mode: tool keys, Delete, Enter, Tab, F and the
command shortcuts. A two-finger tap undid. With a tool that claims
Escape, such as the Intertwine tool, Escape did not leave the mode.

In the mode only the Escape, Left Arrow and Right Arrow keys now act,
and Escape leaves the mode whatever the tool. Every other key press,
the text it types and the clipboard keys are dropped before any
shortcut reads them, the macOS menu bar's key equivalents included. A
right click or a long touch opens no context menu, a two- or
three-finger tap undoes or redoes nothing, and the pointer over the
canvas is the default one. F and Shift+F no longer leave the mode;
Escape does.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo 34ae88292d Links: Shift-click selects a range of links
In the Links panel, Shift-click and Cmd/Ctrl-click both added or
removed the image of the one row clicked. Shift-click now selects the
images of every row from the last row clicked without Shift to the
clicked one, in the order the panel shows them (its Show filter and
Sort order), and Cmd/Ctrl-Shift-click adds them to the selection.
Cmd/Ctrl-click still adds or removes one image, a plain click selects
one, and a double-click still goes to the link. When the last row
clicked is not shown, or the last click was in another document,
Shift-click acts as a click without Shift.

A click on a row runs select.set, select.add or select.toggle, and the
range is worked out as in the Layers panel's Shift-click.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo a8fbae1b1f Graphic Styles: Shift-click selects a range of styles
In the Graphic Styles panel, Shift-click and Cmd/Ctrl-click both added
or removed the one style clicked. Shift-click now adds to the selection
every style from the last one clicked without Shift through the
clicked one, in the order the panel lists them, and Cmd/Ctrl-click
still adds or removes one. A plain click applies the style and selects
it alone, Alt-click still adds it on top, and the Control bar's Style
picker still applies on every click.

The panel uses swatches::click_selection, as the Swatches panel and
the library panel do.

Docs: docs/target-app-parity.md.
2026-10-17 13:20:23 +07:00
dotCooCoo 3c5d0e7ce4 Libraries: a plain click sets where a Shift-click range starts
A plain click on an item in the library panel selected the item but did
not record it as the anchor that a Shift-click ranges from. A later
Shift-click then selected only the clicked item, or ranged from an
older anchor set by an earlier Cmd/Ctrl-click or folder click. This
applied to swatch libraries and graphic style libraries.

Every click on an item or folder now goes through click_selection, as
in the Swatches panel. A plain click still selects the item alone and
adds and applies it.
2026-10-17 13:20:23 +07:00
dotCooCoo fbcb217789 Part of #2384: imported PDF text keeps its place after wide gaps between words
PDF import reads a gap wider than a fifth of an em between two glyphs
as a space, and a space that the line's tracking cannot place gets
tracking of its own. That tracking was clamped to 1000 thousandths of
an em, while type takes tracking from -1000 to 10000. A gap wider than
an em plus the width of a space came in short by the excess, so the
words after it sat to the left of where the file put them, and each
further wide gap added to the shift.

The tracking that import sets for such a space, and for the line around
it, now has the same range as type's.
2026-10-17 13:20:23 +07:00
Brian TorresandClaude Opus 5.5 7c80b286c7 Fix #2540: Object → Expand expands Intertwine groups
Expand had nothing for Intertwine groups. It reported success but left
the group live (it only dropped the members' live shapes), and its
Stroke and Fill options could not reach the members, so a second Expand
found nothing to do. Ungroup takes apart plain groups only, so it did
nothing either.

Expand's Object option now turns every Intertwine group in the selection
(inside groups too, inner ones first) into a plain group of what it
draws: its objects, keeping their ids and order, then each part drawn
over the others as a clip group. The group keeps its id, place,
transparency and appearance, everything happens in one undo step, and
the dialog enables Object for an Intertwine group. Ungroup then takes
the result apart, as with an expanded blend or repeat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:23 +07:00
vangel76andClaude Opus 5.5 91fa3af21d Export: raster document.export takes a page rectangle (rect) (#2412)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:23 +07:00
vangel76andClaude Opus 5.5 577239d734 Gradient chips: follow the angle and the gradient type (#2494)
gradient_chip now takes the GradientPaint and lays the gradient out as it would be on an object of the chip's shape: linear along its angle, radial from the centre, freeform from its colour points. Gradient panel swatch menu and the Swatch Options dialog carry the full paint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:20:22 +07:00
bflatastic 39979d4753 Tests: the floating-panel test skips fills that paint nothing (#2421) 2026-10-17 13:20:22 +07:00
bflatastic 424a49604c Layers: Merge Selected keeps to the highlighted layers, as documented (#2383)
Reverts the fallback that merged the layers of art selected on the canvas when no
layer row is highlighted: Illustrator's Merge Selected merges the highlighted rows
into the last one highlighted.
2026-10-17 13:06:52 +07:00
bflatastic 47b6b3285d Panels: floating groups get an opaque fill beneath them; the docked card keeps its rounded corners (#2421) 2026-10-17 13:06:52 +07:00
vangel76andClaude Opus 5.5 ecbbba4f2e Panels: floating panel groups paint an opaque background (#2421)
The shared dock left its pane and tab strip backgrounds transparent because the docked card behind them is filled. Floating groups lie on the canvas, so the canvas showed through. They now paint the panel colour, which is what the card shows when docked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:52 +07:00
bflatastic 2a3efa02f2 Overprint Preview: overprinting characters draw with Darken too (#2415) 2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 8ac4c9d268 Color: Generic CMYK compensates black both ways so black round-trips (#2493)
sRGB to Generic CMYK applies black-point compensation (black separates to the
darkest rich black) but CMYK to display did not undo it, so hex 000000 became
C67 M67 Y67 K100 and then showed as #1b1818. The display XYZ now maps the press
black to black when the colour settings use black-point compensation, in the
sRGB conversion, the ink lookup table the canvas uses, Lab of a CMYK colour and
the gamut warning.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 d39e02b3fe Type: selected overflowing type shows its red out port with any tool (#2323)
The overflow marker was drawn by the Type tools only, so with the Selection tool a text box smaller than its text showed no sign that text was hidden. Session::overlays now adds the marker of every selected area type and type on a path that overflows, unless a Type tool is active (it draws its own). The verdict is cached per object and font generation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 5fd7fd4312 Character: the Kerning field at a caret kerns the pair, not the whole text (#2217)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 57c79dc0b4 PNG export: an sRGB chunk marks the colours as sRGB, embedIcc turns it off (#2221)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
bflatastic 1fbd35560d Tests: the cursor-theme test compares paths, so it passes on Windows (#2450) 2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 fdc08e25d6 Layers: Merge Selected merges the layers of art selected on the canvas (#2383)
With no highlighted rows and art selected in two or more layers (Select All), layer.merge merges those layers; the panel menu enables it then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 7e423f91e9 Overprint Preview: overprinting draws with Darken, so a colour over itself stays that colour (#2415)
Multiply squared a colour overprinting itself (stroke over same-colour fill showed a darker band). Darken keeps the stronger ink per plate/channel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
vangel76andClaude Opus 5.5 1ff7ac7557 Layers: Merge Selected with a layer and its sublayer highlighted merges into the last top-level layer (#2383)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 13:06:51 +07:00
dotCooCoo 476d228262 Tests: a language one ui-egui test sets no longer translates the tests running beside it
The language the UI is drawn in was one process-wide value in unit
tests too, and the tests run on parallel threads.
current_language_is_a_frame_setting sets Traditional Chinese for a
moment, and a test that drew text in that moment read it in Chinese.
chrome::tests::the_zoom_hint_follows_animated_zoom failed that way in
29 of 500 runs together with the i18n tests.

The drawing language is now one per process in the app and one per
thread in unit tests, as ui_mirror! keeps the shortcut and workspace
mirrors. The app keeps the same static. A new test sets the language
on another thread and checks that its own thread still draws in
English.
2026-10-17 12:50:38 +07:00
dotCooCoo 6961bed99d Tests: CLI tests do not fail on folders an earlier run left behind
The tmp() helpers in apps/vectorcraft-cli/tests/cli.rs and fileio.rs
name their folder after the process id (vectorcraft-cli-test-<pid>,
vectorcraft-cli-fileio-<pid> in the temp folder) and never empty or
remove it. When a later run gets the same process id, it finds the
earlier run's files there. mcp_automation_roots then fails on Unix with
EEXIST (Os { code: 17, kind: AlreadyExists, message: "File exists" }):
its first symlink() call finds the link in/escape.svg that the earlier
run made. A stale output file can also let a fileio test read an
earlier run's export.

The first tmp() call in each test process now removes the folder and
creates it again empty. The roots test's body moves into
automation_roots(), and mcp_automation_roots_in_a_folder_an_earlier_run_left
runs it twice in one folder, emptying the folder in between as tmp()
does. Without the removal the second run fails with the same error.
2026-10-17 12:50:38 +07:00
vangel76andClaude Opus 5.5 37dfdd5da5 Cursor: keep the system pointer where no cursor theme loads on Wayland (#2450)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:50:38 +07:00
vangel76andClaude Opus 5.5 743930ac11 Wayland: give winit the desktop cursor theme so the pointer returns over panels (#2450)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 12:50:38 +07:00
dotCooCoo efdb04db8a Lint: action_batch.rs compiles without an unused import on wasm32
On main, cargo xtask wasm printed an unused-import warning for json in
crates/engine/src/cmd/fileio/action_batch.rs, and this check reported
it as an error:

    RUSTFLAGS=-Dwarnings cargo check -p vectorcraft-engine --target wasm32-unknown-unknown

Every use of json! in action_batch.rs is in code compiled only for
native targets. The wasm32 version of action_batch uses Value only.

The json import now has the same #[cfg(not(target_arch = "wasm32"))]
attribute as the code that uses it. Native builds import the same
names as before.
2026-10-17 12:50:37 +07:00
NetterSilverandClaude Haiku 5.5 80685831ac Escape XML ID first characters that cannot start an XML name
Found while testing crates/doc/src/xml_id.rs:31 (from_object_name) / :10 (to_object_name)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 12:50:37 +07:00
NetterSilverandClaude Haiku 5.5 3688ad7a46 Reject '+' and other non-hex characters in Color::from_hex
Found while testing crates/color/src/lib.rs:184

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 12:50:37 +07:00
NetterSilverandClaude Haiku 5.5 8ca4cd2cbf Fix graph axis labels saturating beyond the i64 range
Found while testing crates/engine/src/cmd/graph.rs:193 (fmt_value)

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-17 12:50:37 +07:00
dotCooCoo 3d2e8bbe82 PDF export: Add Around is clamped when a file opens, and art too large for f64 gets no effect image
document.rasterEffectsSettings accepts addAround from 0 to 1,000 pt,
but a file's raster_effects.addAround opened as it was saved. With
addAround 1e300, the area of a raster effect image overflowed f64,
the image scale became 0 and the image got the transform
[inf, NaN, NaN, inf, x, y]. PDF export left that image out, so the
drop shadow was missing.

A file's addAround now opens clamped to 0 to 1,000 pt. An effect
image whose area or scale is not finite is not made. Export then
keeps the object with its effects, and Expand Appearance expands it
without an image, as when its PNG can't be encoded.

Docs: docs/mcp.md (addAround).
2026-10-17 12:50:36 +07:00
bflatastic 0af1741e24 Crop Image: the RGBA size check can't overflow on a huge header 2026-10-17 12:50:36 +07:00
dotCooCoo db2bbcf405 Images: sampling and recoloring check an image's RGBA size without overflowing
Before the Eyedropper samples an image, or Edit Colors, Recolor Artwork
or a color adjustment effect recolors an embedded one, `rgba_within` reads
the image's size from its header and checks its size as RGBA against the
allowance. The check multiplied width × height × 4 in 64 bits. A TIFF's
sides go up to 2^32 - 1 pixels, so a header of 2^31 × 2^31 pixels
overflowed that product: debug and test builds panicked with "attempt to
multiply with overflow", and release builds wrapped it to 0 before the
decoder's own 512 MB limit refused the image.

The check now compares width × height with the allowance divided by 4.
Every size whose RGBA bytes fit in 64 bits gets the same answer as
before, and a larger one is refused.
2026-10-17 12:48:19 +07:00
dotCooCoo 9bee299dd5 Render: an export no longer warns about images another thread could not decode
A raster export compared the process-wide count of images that could
not be decoded before and after it drew, and warned that "an image
couldn't be decoded and is missing from the picture" when the count
rose. Any other thread that failed to decode an image in that time
raised the count too: a second export, the UI thread drawing the
canvas, or the canvas, thumbnail and placed-document workers drawing a
document with such an image. The export then warned about an image it
does not contain. In the engine's tests,
options_not_applied_yet_and_document_features_warn failed in 5 of 60
runs of the tests_raster and tests_pdf* tests.

The count is now kept per thread. An export reads it on the thread it
draws on. That thread decodes every image the export draws that is not
already in the shared image store, and an image that failed to decode
is never stored.
2026-10-17 12:48:19 +07:00
dotCooCoo 0f987812b0 Render: check an image's RGBA size before decoding it
The renderer decoded each image within its 2 GB allowance (512 MB in
the web build) and then converted it to RGBA. The decoder holds the
decoded image, before conversion, to that allowance, so an image stored
with fewer than four bytes a pixel (gray, 1-bit, gray with alpha, or
RGB without alpha) could take up to four times as much (4/3 times for
RGB) once converted, and as much again as the pixmap drawn from it.

The renderer now reads the image's size from its header first. When
width × height × 4 is over the allowance it does not decode the image
and handles it as one it can't decode: the renderer draws nothing for
it, and a raster export warns that an image is missing. Images within
the allowance are decoded and drawn as before.
2026-10-17 12:48:19 +07:00
dotCooCoo 6b0d1ed1c0 Crop Image: trim checks the image's RGBA size before decoding it
Crop Image's trim (object.cropImage {trim: true}) decoded the image
within MAX_RASTER_ALLOC (2 GB) and then converted it to RGBA. The
decoder holds the decoded image, before conversion, to that allowance,
so a gray or 1-bit image within it could take up to four times as much
once converted.

Trim now reads the image's size from its header first and, when
width × height × 4 is over the allowance, fails before anything is
decoded, with the check and message Load Texture uses: "an image of
W × H pixels is too large to draw". Images within the allowance are
trimmed as before.
2026-10-17 12:48:19 +07:00
BFlat 49bdc5cdd4 Merge pull request #1510 from marcoarnulfo/fix/ai-layers-collapsed
Layers: a .ai another app wrote opens with its top-level layers closed, as its groups
2026-10-17 12:46:37 +07:00
BFlat 7a8387f467 Merge pull request #1365 from Messhias/feat/394-highlight-alternate-glyphs
Part of #394: Highlight Alternate Glyphs paints pink behind OpenType substitutions
2026-10-17 12:46:18 +07:00
BFlat 12758df31f Merge pull request #2334 from bobtherobot/feat/tool-cursors
Cursors: 36 more tool states get cursors of their own, listed in cursors.toml
2026-10-17 12:46:03 +07:00
BFlat a76faaa21f Merge pull request #2393 from pechnikov2026-star/i18n-nl-da-hi-id
i18n: add Dutch, Danish, Hindi and Indonesian interfaces
2026-10-17 12:45:46 +07:00
BFlat 21a9da7360 Merge pull request #2379 from dotCooCoo/fix/macos-save-format-menu
Fix #866: macOS save sheets have a Format menu again
2026-10-17 12:45:28 +07:00
Marco Arnulfo f31d91bf1a Layers: a .ai another app wrote opens with its top-level layers closed, as its groups (fixes #1475) 2026-10-17 12:26:14 +07:00
bflatastic 52771905e6 Part of #394: rustfmt the alternate-glyph test 2026-10-17 12:26:14 +07:00
Fabio William Conceição 0ffe0c7c0e Part of #394: Highlight Alternate Glyphs defaults to off. 2026-10-17 12:26:13 +07:00
Fabio William Conceição 8689863fbf Part of #394: Highlight Alternate Glyphs paints pink behind OpenType substitutions. 2026-10-17 12:26:13 +07:00
Michael GiesonandClaude Opus 5.5 2d0da1472f Cursors: 36 more tool states get cursors of their own, listed in cursors.toml
Node editing as the public manual describes it: Direct Selection's anchor squares (hollow
or filled), Reshape Segment over a curved segment, Group Selection's plus (also Direct
Selection with Alt), the arrow hollow while a drag snaps, and Alt-copy arrowheads. The
Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows (ShearH,
ShearV) are drawn in code. Lasso, the freehand, symbolism and other tools return cursors
of their own that borrow their old look until drawn (Cursor::stand_in).

crates/tools/src/cursors.toml lists every Cursor value, when it shows and its hotspot,
for whoever draws them; `cargo xtask cursors` (in ci) warns about a missing, unknown,
repeated or incomplete entry and never fails. The busy cursor is listed as missing (G1).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-17 12:26:12 +07:00
sanches 816097c6bf i18n: add Dutch, Danish and Indonesian interfaces
Complete catalogs for nl, da and id, registered with their locales. Hindi from the same PR waits for a Devanagari UI font decision (with Lao and Georgian), and the strings main gained since the catalogs were written are added.
2026-10-17 12:26:11 +07:00
dotCooCoo 1d7ad173aa Fix #866: macOS save sheets have a Format menu again
On macOS, the save sheets of Save As, Save a Copy and the first Save of
a new document have a Format menu again, and the Save as Template sheet
now has one too. The panel is an NSSavePanel built in
apps/vectorcraft/src/mac_save_format.rs, and the menu is its accessory
view, set before beginSheetModalForWindow:completionHandler: shows the
sheet. The menu from #1235 was added after rfd had begun the sheet, and
recent macOS versions dismissed the sheet at once (#1800, #1831,
#1518); #1855 removed it.

Choosing a format changes the extension in the name field and puts
that format's extension first in the allowed file types, which AppKit
adds to a name typed without one. A name typed with another listed
extension is saved in that format. Save panels with one file type and
the open and folder panels are still shown by rfd.

Docs: docs/target-app-parity.md, docs/ui-parity.md.
2026-10-17 12:26:10 +07:00
263 changed files with 27436 additions and 1162 deletions

No files matched your search

+1
View File
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
- **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`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
+1
View File
@@ -41,6 +41,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
- **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`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
+3 -1
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
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (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-17 · **Change:** minor (about 75 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
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -134,7 +134,9 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-17 | About 75 community PRs landed in three 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). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). |
| 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5).
| 2026-10-15 | Cursors: 36 more tool states get cursors of their own. Node editing follows the public manual (Direct Selection's anchor squares, Reshape Segment, Group Selection's plus, the arrow hollow on a snap, the Alt-copy arrowheads), the Anchor Point caret, the Curvature pen and Free Transform's side-shear arrows are drawn; Lasso, the freehand, symbolism and other tools borrow a drawn cursor until theirs is drawn. `crates/tools/src/cursors.toml` lists every cursor for whoever draws them, and `cargo xtask cursors` warns about one missing from it. Scores unchanged. |
| 2026-10-16 | About 160 community PRs landed, each through the full gate. Crashes and hostile files: damaged PDF dictionaries and arrays no longer crash import (#2133, #2240, #2244, #2271), area type with huge row or column counts opens (#2109), hostile mesh edges are bounded (#2051). New: Dimension tool (#1788), legacy version 7 `.ai` save (#1794), Object › Intertwine (#2095), Apple Pencil and touch input on the web (#1740), Sample Color from Screen (#2257), Drop Shadow Darkness (#2252), Smart Blur edge modes (#2154), Scribble variations (#2180), Free Distort corners in its dialog (#2134), Rotate View presets (#2106), tritanopia proofing (#2234), Layers panel filters (#2087), Hide Template (#2117), File › Automate Batch through the Actions panel (#1938), dataset and graph CSV (#1929, #1941), Reset Tracking and Kerning (#2274), Alt+Enter copies in Transform fields (#2236), Shift+Tab panels (#2254), Control bar isolation, Recolor and image Replace (#2258, #2079, #2081). Revolve renders smooth with Cap on by default (#1193); Image Trace keeps tiny parts, thin outlines and shared corners, and Grayscale gradients are grey (#2012, #2025, #2263, #2264, #2265, #1879). Import: many `.ai`, EPS and PDF fidelity fixes (#2014–#2016, #2029, #2030, #2044, #2046, #2050–#2052, #2058, #2129, #2199, #2202, #2218), images up to 1 GB decoded (#2060), `.ai`/`.ait`/`.svgz` files paste from a file manager (#2148, #2201). Fixes across fields (arithmetic #2069, #2209, #2022; caret #1403), New Document (#2158, #2176), menus (#2232), shortcuts (#2235, #2146), Transform Each axes (#2227), Paste in Place (#1926), Color Picker tab order (#1937, #1939) and more. Languages: Bulgarian, Greek, Swedish and Finnish complete under the tests, pt-br completed. Details per PR in the PRs; headline numbers unchanged (estimated). |
| 2026-10-14 | Brazilian Portuguese (`pt-br`) covers the whole interface and status/error messages under the completeness tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85. |
| 2026-10-14 | Direct Selection: deleting an interior anchor of an open path leaves separate surviving runs. Delete/Backspace and history regressions cover the gap; explicit Remove Anchors remains connected. Headline numbers unchanged. |
+14 -5
View File
@@ -159,6 +159,15 @@ fn main() -> ExitCode {
}
}
/// A headless session that remembers what the user keeps between runs: the PDF presets saved and
/// the ICC profiles loaded (#2168, #2174), in the folders next to the preferences that the desktop
/// app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there.
fn with_stores(mut h: Headless) -> Headless {
prefs_dir::attach_profile_store(&mut h.session);
prefs_dir::attach_pdf_preset_store(&mut h.session);
h
}
fn mcp(args: &[String]) -> Result<(), String> {
let mut connect: Option<String> = None;
let mut headless = false;
@@ -198,14 +207,14 @@ fn mcp(args: &[String]) -> Result<(), String> {
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)))
} else if headless {
Box::new(Headless::with_document())
Box::new(with_stores(Headless::with_document()))
} else if let Some(addr) = connect {
// Explicit address: fail loudly if the app isn't there.
Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?)
} else {
match Remote::connect(DEFAULT_ADDR) {
Ok(r) => Box::new(r),
Err(_) => Box::new(Headless::with_document()),
Err(_) => Box::new(with_stores(Headless::with_document())),
}
};
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
@@ -213,8 +222,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
vectorcraft_mcp::logging::install();
let stdin = std::io::stdin();
let stdout = std::io::stdout();
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
// The server reads and writes from threads of its own, so it takes the handles, not locks held here.
Server::new(backend).serve(std::io::BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
}
fn commands() -> Result<(), String> {
@@ -327,7 +336,7 @@ fn run(args: &[String]) -> Result<(), String> {
}
}
let mut h = Headless::new();
let mut h = with_stores(Headless::new());
let mut out = std::io::stdout().lock();
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
// steps (an export…) still run, unprinted.
+118 -3
View File
@@ -7,10 +7,18 @@ use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// Returns the path of `name` in `vectorcraft-cli-test-<pid>` in the temp folder. The first call removes
/// that folder and creates it again empty.
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id()));
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id())))).join(name)
}
/// Removes `dir` and everything in it, creates it again empty and returns it.
fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
dir
}
#[test]
@@ -87,7 +95,20 @@ fn mcp_headless_over_stdio() {
/// server starts, and the process then reads and writes only inside its roots.
#[test]
fn mcp_automation_roots() {
let base = tmp("roots");
automation_roots(&tmp("roots"));
}
/// A run that has the process id of an earlier run finds that run's files and links in its folder.
/// `empty_dir`, which `tmp` uses, removes them, so the run can make them again instead of failing
/// with "File exists".
#[test]
fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
let dir = tmp("earlier-run");
automation_roots(&dir.join("roots"));
automation_roots(&empty_dir(dir).join("roots"));
}
fn automation_roots(base: &std::path::Path) {
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
@@ -346,3 +367,97 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
assert!(!out.status.success());
}
/// A fresh settings folder for one test (`VECTORCRAFT_CONFIG_DIR`), so no run reads or writes the
/// developer's real profile.
fn config_dir(name: &str) -> std::path::PathBuf {
let dir = tmp(&format!("config-{name}"));
let _ = std::fs::remove_dir_all(&dir);
dir
}
/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`;
/// the result of each step.
fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec<Value> {
let mut cmd = Command::new(BIN);
cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS");
for (id, params) in steps {
cmd.args(["--cmd", id, "--params", &params.to_string()]);
}
let out = cmd.output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()["result"].clone()).collect()
}
/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a
/// name of the same length).
fn icc_file_named(path: &std::path::Path, name: &str) {
let builtin = "Display P3";
assert_eq!(builtin.len(), name.len());
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
let mut bytes = vectorcraft_color::cms::icc_bytes(builtin).unwrap().to_vec();
let (from, to) = (utf16(builtin), utf16(name));
let at = bytes.windows(from.len()).position(|w| w == from).expect("the description in the profile");
bytes[at..at + to.len()].copy_from_slice(&to);
std::fs::write(path, bytes).unwrap();
}
fn profile_names(settings: &Value) -> Vec<&str> {
settings["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect()
}
/// #2168, #2174: the PDF presets saved and the ICC profiles loaded in one headless run are there in
/// the next run, and gone again once deleted.
#[test]
fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() {
let config = config_dir("restart");
let icc = tmp("studio-p3.icc");
icc_file_named(&icc, "StudioRGB3");
let r = run_in(
&config,
&[
("pdf.preset.save", json!({"name": "Press", "description": "for the printer"})),
("color.loadProfile", json!({"path": icc.to_str().unwrap()})),
],
);
assert_eq!(r[1]["name"], "StudioRGB3");
let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]);
let presets: Vec<&str> =
r[0]["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!(presets, ["Press"]);
assert_eq!(r[0]["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Press").unwrap()["description"], "for the printer");
assert!(profile_names(&r[1]).contains(&"StudioRGB3"), "{:?}", profile_names(&r[1]));
// The profile works as a working space, and the file it came from can go.
std::fs::remove_file(&icc).unwrap();
let steps = [
("edit.colorSettings", json!({"rgb": "StudioRGB3"})),
("pdf.preset.delete", json!({"name": "Press"})),
("edit.colorSettings", json!({"rgb": vectorcraft_color::cms::SRGB})),
("color.removeProfile", json!({"name": "StudioRGB3"})),
];
let r = run_in(&config, &steps);
assert_eq!(r[0]["rgb"], "StudioRGB3");
assert_eq!(r[3]["removed"], "StudioRGB3");
let r = run_in(&config, &[("pdf.preset.list", json!({})), ("edit.colorSettings", json!({}))]);
assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
assert!(!profile_names(&r[1]).contains(&"StudioRGB3"));
}
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`) keep what they save for the run only, and
/// leave nothing in the settings folder.
#[test]
fn runs_without_preferences_remember_nothing() {
let config = config_dir("noprefs");
let out = Command::new(BIN)
.args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#])
.env("VECTORCRAFT_CONFIG_DIR", &config)
.env("VECTORCRAFT_NO_PREFS", "1")
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(!config.exists(), "nothing written");
let r = run_in(&config, &[("pdf.preset.list", json!({}))]);
assert!(r[0]["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == true));
}
+9 -2
View File
@@ -8,9 +8,16 @@ use serde_json::Value;
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// Returns the path of `name` in `vectorcraft-cli-fileio-<pid>` in the temp folder. The first call
/// removes that folder and creates it again empty.
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
static DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
let dir = DIR.get_or_init(|| {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
});
dir.join(name).to_string_lossy().to_string()
}
+212
View File
@@ -0,0 +1,212 @@
//! Linux Wayland: tell winit where the cursor theme is (#2450).
//!
//! Without the cursor-shape protocol (GNOME 46 and others) winit loads the pointer from an Xcursor
//! theme itself. When that lookup finds nothing, no pointer is ever set and the compositor keeps
//! showing its default one, until the canvas hides the system pointer to paint a tool glyph
//! (`CursorIcon::None`). Then the arrow never comes back over the panels and menus. The lookup
//! fails in a Flatpak sandbox (`XCURSOR_PATH` unset, no cursor theme in the runtime) and where the
//! `default` theme is a dangling symlink; the desktop's own setting names a theme that loads.
//!
//! Where no cursor theme can be found at all (KDE or sway without gsettings), the env fix does
//! nothing: [`theme_missing`] tells the canvas to keep the system pointer visible beside the tool glyph.
//!
//! This reads the desktop's cursor theme and size and sets what the user left unset, once, before
//! any thread starts and before the window opens.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
/// Where a Flatpak sandbox mounts the host's cursor themes (user and system).
const FLATPAK_HOST_ICONS: [&str; 2] = ["/run/host/user-share/icons", "/run/host/share/icons"];
/// The desktop's cursor settings (GNOME's `org.gnome.desktop.interface`).
#[derive(Debug, Default, PartialEq)]
pub struct DesktopCursor {
pub theme: Option<String>,
pub size: Option<u32>,
}
/// The variables to set, given the environment, the host icon folders that exist and the
/// desktop's cursor settings. Empty unless this is a Wayland session; variables the user set
/// stay as they are.
pub fn plan(var: impl Fn(&str) -> Option<OsString>, host_icons: &[&str], desktop: DesktopCursor) -> Vec<(&'static str, OsString)> {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
if !(set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) {
return Vec::new();
}
let mut out = Vec::new();
if !set("XCURSOR_PATH") && !host_icons.is_empty() {
out.push(("XCURSOR_PATH", OsString::from(host_icons.join(":"))));
}
if !set("XCURSOR_THEME")
&& let Some(theme) = desktop.theme.filter(|t| !t.is_empty())
{
out.push(("XCURSOR_THEME", OsString::from(theme)));
}
if !set("XCURSOR_SIZE")
&& let Some(size) = desktop.size.filter(|s| (1..=512).contains(s))
{
out.push(("XCURSOR_SIZE", OsString::from(size.to_string())));
}
out
}
/// Xcursor's default search path when `XCURSOR_PATH` is unset (relative entries are in `$HOME`).
const DEFAULT_ICON_DIRS: [&str; 5] = ["~/.local/share/icons", "~/.icons", "/usr/share/icons", "/usr/share/pixmaps", "/usr/X11R6/lib/X11/icons"];
/// The themes `index.theme` lists after `Inherits=`.
fn inherits(index: &str) -> Vec<String> {
index
.lines()
.find_map(|l| l.trim().strip_prefix("Inherits="))
.map(|v| v.split([',', ';']).map(|t| t.trim().to_owned()).filter(|t| !t.is_empty()).collect())
.unwrap_or_default()
}
/// Can Xcursor load a pointer for the environment's theme (`XCURSOR_THEME`, else `default`)? It does
/// when the theme, or one it inherits from, has a `default` or `left_ptr` cursor file in a folder of
/// `XCURSOR_PATH` (else Xcursor's default folders). `read` returns a file's text, `exists` tells
/// whether a file is there.
pub fn theme_usable(
var: impl Fn(&str) -> Option<OsString>,
home: Option<&Path>,
read: impl Fn(&Path) -> Option<String>,
exists: impl Fn(&Path) -> bool,
) -> bool {
let dirs: Vec<PathBuf> = match var("XCURSOR_PATH").filter(|v| !v.is_empty()) {
Some(v) => std::env::split_paths(&v).collect(),
None => DEFAULT_ICON_DIRS
.iter()
.filter_map(|d| match d.strip_prefix("~/") {
Some(rest) => home.map(|h| h.join(rest)),
None => Some(PathBuf::from(d)),
})
.collect(),
};
let first = var("XCURSOR_THEME").and_then(|v| v.into_string().ok()).filter(|t| !t.is_empty()).unwrap_or_else(|| "default".into());
let mut todo = vec![first];
let mut seen: Vec<String> = Vec::new();
while let Some(theme) = todo.pop() {
// Bounded: themes inherit in short chains, and a cycle must end.
if seen.contains(&theme) || seen.len() >= 16 {
continue;
}
for dir in &dirs {
let base = dir.join(&theme);
if ["default", "left_ptr"].iter().any(|c| exists(&base.join("cursors").join(c))) {
return true;
}
if let Some(index) = read(&base.join("index.theme")) {
todo.extend(inherits(&index));
}
}
seen.push(theme);
}
false
}
/// On this machine, after [`apply`]: is there no cursor theme Xcursor can load? Then winit never
/// sets a pointer, and the canvas must not hide the system one (#2450).
pub fn theme_missing() -> bool {
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
!theme_usable(|n| std::env::var_os(n), home.as_deref(), |p| std::fs::read_to_string(p).ok(), |p| p.exists())
}
/// The value of a `gsettings get` line: `'Adwaita'` becomes `Adwaita`, `24` stays `24`.
fn gsettings_value(output: &str) -> &str {
output.trim().trim_matches('\'')
}
#[cfg(target_os = "linux")]
fn desktop_cursor() -> DesktopCursor {
let get = |key: &str| {
let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", key]).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
};
DesktopCursor {
theme: get("cursor-theme").map(|v| gsettings_value(&v).to_owned()),
size: get("cursor-size").and_then(|v| gsettings_value(&v).parse().ok()),
}
}
/// Sets `XCURSOR_PATH`, `XCURSOR_THEME` and `XCURSOR_SIZE` where a Wayland session leaves them
/// unset. Call first thing in `main`, while the process is single-threaded.
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
pub fn apply() {
let host: Vec<&str> = FLATPAK_HOST_ICONS.iter().copied().filter(|p| std::path::Path::new(p).is_dir()).collect();
for (name, value) in plan(|n| std::env::var_os(n), &host, desktop_cursor()) {
log::debug!("cursor theme: {name}={value:?}");
// SAFETY: called at the top of `main`, before the app starts any thread.
unsafe { std::env::set_var(name, value) };
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
move |name| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| OsString::from(v))
}
fn adwaita() -> DesktopCursor {
DesktopCursor { theme: Some("Adwaita".into()), size: Some(24) }
}
#[test]
fn a_wayland_session_gets_the_desktop_theme_and_the_host_icons() {
let got = plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &["/run/host/share/icons"], adwaita());
assert_eq!(
got,
vec![
("XCURSOR_PATH", OsString::from("/run/host/share/icons")),
("XCURSOR_THEME", OsString::from("Adwaita")),
("XCURSOR_SIZE", OsString::from("24")),
]
);
}
#[test]
fn settings_the_user_made_and_other_sessions_stay_untouched() {
let set = [("WAYLAND_DISPLAY", "wayland-0"), ("XCURSOR_PATH", "/x"), ("XCURSOR_THEME", "Bibata"), ("XCURSOR_SIZE", "32")];
assert!(plan(env(&set), &["/run/host/share/icons"], adwaita()).is_empty());
assert!(plan(env(&[("DISPLAY", ":0")]), &["/run/host/share/icons"], adwaita()).is_empty());
// A theme-less desktop and an absurd size set nothing.
let odd = DesktopCursor { theme: Some(String::new()), size: Some(0) };
assert!(plan(env(&[("WAYLAND_DISPLAY", "wayland-0")]), &[], odd).is_empty());
}
#[test]
fn a_theme_is_usable_directly_or_through_inherits_and_missing_otherwise() {
let files = ["/i/Bibata/cursors/left_ptr", "/i/Breeze/index.theme", "/i/Loop/index.theme"];
// Compared as paths, so the joins match on Windows too, where they use `\`.
let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p);
let read = |p: &Path| {
if p == Path::new("/i/Breeze/index.theme") {
Some("[Icon Theme]\nInherits=Other, Bibata\n".to_owned())
} else if p == Path::new("/i/Loop/index.theme") {
Some("Inherits=Loop\n".to_owned())
} else {
None
}
};
let with = |theme: &'static str| {
move |n: &str| (n == "XCURSOR_PATH").then(|| OsString::from("/i")).or((n == "XCURSOR_THEME").then(|| OsString::from(theme)))
};
assert!(theme_usable(with("Bibata"), None, read, exists));
assert!(theme_usable(with("Breeze"), None, read, exists));
// No such theme, a theme without cursors, an inheritance loop: nothing to load.
assert!(!theme_usable(with("Nope"), None, read, exists));
assert!(!theme_usable(with("Loop"), None, read, exists));
// Unset: Xcursor's default folders and the `default` theme.
assert!(!theme_usable(|_| None, Some(Path::new("/home/u")), read, exists));
}
#[test]
fn gsettings_output_is_unquoted() {
assert_eq!(gsettings_value("'Adwaita'\n"), "Adwaita");
assert_eq!(gsettings_value("24\n"), "24");
}
}
+253
View File
@@ -0,0 +1,253 @@
//! macOS: the Format menu on save sheets (#866). A save panel with two or more file types (Save As,
//! Save a Copy, Save as Template and the first Save of a new document) is an `NSSavePanel` made here
//! instead of by rfd, whose Cocoa backend lists the extensions of every filter but not their names.
//! The panel gets a Format menu as its accessory view
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/accessoryview>) before
//! `beginSheetModalForWindow:completionHandler:`
//! (<https://developer.apple.com/documentation/appkit/nssavepanel/beginsheetmodal(for:completionhandler:)>)
//! shows it: an accessory set after the sheet began made recent macOS versions dismiss the sheet at
//! once (#1800, #1831, #1518). Choosing a format changes the extension in the name field and puts
//! the format's extension first in the allowed file types, which AppKit adds to a name typed without
//! one. The save command takes the format from the extension, so an extension typed by hand still
//! decides it.
//!
//! The panel retains the accessory, and the completion handler holds the panel until the sheet
//! closes; the popup's target is the accessory itself. The accessory holds the panel weakly, so
//! closing the sheet releases both. Reading the window from its handle and subclassing `NSView` for
//! the menu's action have no safe API; this module allows `unsafe_code`.
#![allow(unsafe_code)]
use std::sync::mpsc::{Receiver, channel};
use block2::RcBlock;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
/// The accessory's height and the room around and between its label and menu, in points.
const HEIGHT: f64 = 38.0;
const MARGIN: f64 = 12.0;
const GAP: f64 = 8.0;
/// The Format menu's size, in points.
const MENU: NSSize = NSSize::new(260.0, 26.0);
struct Ivars {
panel: Weak<NSSavePanel>,
filters: Vec<Filter>,
}
define_class!(
// SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop.
#[unsafe(super = NSView)]
#[thread_kind = MainThreadOnly]
#[ivars = Ivars]
#[name = "VectorcraftSaveFormatAccessory"]
struct FormatAccessory;
// SAFETY: NSObjectProtocol has no additional requirements.
unsafe impl NSObjectProtocol for FormatAccessory {}
impl FormatAccessory {
// SAFETY: AppKit's target/action sends the popup as the single object argument.
#[unsafe(method(chooseFormat:))]
fn choose_format(&self, sender: &NSPopUpButton) {
let Some(panel) = self.ivars().panel.load() else { return };
let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return };
let Some(extension) = self.ivars().filters.get(index).and_then(|(_, extensions)| extensions.first()) else { return };
set_allowed_types(&panel, &self.ivars().filters, index);
let name = panel.nameFieldStringValue().to_string();
panel.setNameFieldStringValue(&NSString::from_str(&filename(&name, extension, &self.ivars().filters)));
}
}
);
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
/// two remain, and the panel is then left to rfd.
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
if named.len() < 2 { Vec::new() } else { named }
}
/// The menu entry of the suggested name's type, or the first (the request's default).
fn initial_format(name: &str, filters: &[Filter]) -> usize {
let extension = fileio::extension(name);
filters.iter().position(|(_, extensions)| extensions.iter().any(|x| extension.eq_ignore_ascii_case(x))).unwrap_or(0)
}
/// `name` with `extension`: a format's extension (one in the menu, or any other the app reads or
/// writes) is replaced, and other dots stay part of the name (a version number).
fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
if name.is_empty() {
return String::new();
}
let has = fileio::extension(name);
let known =
filters.iter().any(|(_, extensions)| extensions.iter().any(|x| has.eq_ignore_ascii_case(x))) || fileio::format_for_name(name).is_some();
if known { std::path::Path::new(name).with_extension(extension).to_string_lossy().into_owned() } else { format!("{name}.{extension}") }
}
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
/// the first one to a name typed without an extension, and a name typed with another of them is
/// saved in that format.
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
first.iter().chain(others).copied().collect()
}
/// Set `panel`'s allowed file types ([`allowed_types`]).
fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
let types: Vec<Retained<NSString>> = allowed_types(filters, selected).into_iter().map(NSString::from_str).collect();
// rfd sets the same deprecated property (`add_filters` in its `panel_ffi.rs`).
#[allow(deprecated)]
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
}
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
/// named file types, or `window` has no AppKit window; nothing is shown then.
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
let mtm = MainThreadMarker::new()?;
let filters = menu_formats(&pick.filters);
if filters.is_empty() {
return None;
}
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
// window is open, and winit hands out the handle only while it is.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let parent = view.window()?;
let panel = NSSavePanel::savePanel(mtm);
let selected = initial_format(&pick.name, &filters);
set_allowed_types(&panel, &filters, selected);
if let Some(folder) = &pick.folder {
panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(folder), true)));
}
panel.setExtensionHidden(false);
if !pick.name.is_empty() {
panel.setNameFieldStringValue(&NSString::from_str(&pick.name));
}
let accessory = accessory(mtm, &panel, filters, selected);
panel.setAccessoryView(Some(&accessory));
let (tx, rx) = channel();
let answered = panel.clone();
let handler = RcBlock::new(move |response: NSModalResponse| {
let picked = answered.URL().and_then(|url| url.path()).map(|p| p.to_string());
let paths = if response == NSModalResponseOK { picked.into_iter().collect() } else { Vec::new() };
// The app gone meanwhile has no use for the answer.
let _ = tx.send(paths);
});
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
// until the sheet closes.
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
Some(rx)
}
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, selected: usize) -> Retained<FormatAccessory> {
let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm);
label.sizeToFit();
let size = label.frame().size;
label.setFrameOrigin(NSPoint::new(MARGIN, (HEIGHT - size.height) / 2.0));
let x = MARGIN + size.width + GAP;
let popup =
NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(x, (HEIGHT - MENU.height) / 2.0), MENU), false);
for (name, _) in &filters {
popup.addItemWithTitle(&NSString::from_str(i18n::tr_ctx(i18n::current(), "file-filter", name)));
}
// The filter list comes from the built-in format registry, small enough for NSInteger.
if let Ok(index) = isize::try_from(selected) {
popup.selectItemAtIndex(index);
}
let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters });
let frame = NSRect::new(NSPoint::ZERO, NSSize::new(x + MENU.width + MARGIN, HEIGHT));
// SAFETY: NSView's designated initializer is called once on the allocated subclass.
let accessory: Retained<FormatAccessory> = unsafe { msg_send![super(this), initWithFrame: frame] };
// SAFETY: the accessory holds the popup as a subview and AppKit keeps the accessory while the
// sheet is open. The selector has the target/action signature NSControl calls it with.
unsafe {
popup.setTarget(Some(&accessory));
popup.setAction(Some(sel!(chooseFormat:)));
}
accessory.addSubview(&label);
accessory.addSubview(&popup);
accessory
}
#[cfg(test)]
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
/// named type, or one for every file, gets no menu, and rfd shows it.
#[test]
fn save_panels_with_several_formats_get_the_menu() {
let save = menu_formats(&fileio::save_filters("vectorcraft"));
assert!(save.len() >= 2, "{save:?}");
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
}
#[test]
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
assert_eq!(initial_format(name, FILTERS), index, "{name}");
}
}
#[test]
fn choosing_a_format_changes_the_extension_and_keeps_the_name() {
for (name, expected) in [
("図面 1.vectorcraft", "図面 1.svg"),
("a.PDF", "a.svg"),
("art", "art.svg"),
("art.v2", "art.v2.svg"),
("art.svg", "art.svg"),
("old.drawcraft", "old.svg"),
("photo.png", "photo.svg"),
("", ""),
] {
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
}
}
/// A name typed without an extension gets the chosen format's: AppKit adds the first allowed
/// type. The others stay allowed.
#[test]
fn the_chosen_format_is_the_first_allowed_type() {
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
}
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
#[test]
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
for (label, extensions) in &filters {
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
}
}
/// The menu's label is translated in every language with complete menus.
#[test]
fn the_label_is_translated() {
for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
assert!(i18n::has(lang, "Format:"), "{}", lang.code());
}
}
}
+27 -8
View File
@@ -27,6 +27,7 @@ mod clipboard;
#[cfg(feature = "wgpu")]
mod compositor_gpu;
mod control_server;
mod cursor_theme;
#[cfg(feature = "wgpu")]
mod gpu;
mod logging;
@@ -35,6 +36,8 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_screen_color;
#[cfg(target_os = "macos")]
mod mac_window;
@@ -473,7 +476,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
})
.ok()?;
make_folder(&request);
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
// The panel is built and the sheet is begun below, on the main thread (as it must).
let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request);
let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: SheetAnswer = match request {
@@ -485,12 +488,18 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
let picked = dialog.pick_files();
Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() })
}
PickRequest::Save(_) => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then made
// recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
// A save panel with two or more named file types is built by `mac_save_format`, which sets
// up its Format menu before the sheet begins (#866); rfd shows the others.
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
// The dialog thread blocks here until the sheet answers.
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
None => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
// made recent macOS versions dismiss the sheet at once (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
},
PickRequest::Folder => {
let picked = dialog.pick_folder();
Box::pin(async move { picked.await.iter().map(path).collect() })
@@ -517,6 +526,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick),
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
no_cursor_theme: wayland && cursor_theme::theme_missing(),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
@@ -646,6 +656,9 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
";
fn main() -> std::process::ExitCode {
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
#[cfg(target_os = "linux")]
cursor_theme::apply();
// Validate roots before logging, font scans, preferences or the window: a missing value
// must not clear an environment root and start the control channel without confinement.
let mut control = automation_args::ControlArg::default();
@@ -841,8 +854,14 @@ fn main() -> std::process::ExitCode {
"VectorCraft",
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
let mut session = Session::new();
// The profiles loaded before come back before the preferences name any of them.
prefs_dir::attach_profile_store(&mut session);
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
load_prefs(&mut app, saved);
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
// which takes over from the copy the preferences file used to hold.
prefs_dir::attach_pdf_preset_store(&mut app.session);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
app.ui.window = Some(window::restore(w, saved_window));
+25
View File
@@ -24,6 +24,31 @@ pub fn install_fonts() {
}
}
/// The folder `name` next to the settings, for what the user keeps: none in a run without
/// preferences (`VECTORCRAFT_NO_PREFS`), which keeps nothing.
fn store_dir(name: &str) -> Option<PathBuf> {
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
return None;
}
Some(prefs_path_for("VectorCraft", "vectorcraft")?.parent()?.join(name))
}
/// Register the ICC profiles `color.loadProfile` kept in the `Color Profiles` folder (#2174), and
/// keep the ones loaded from now on there. Before anything names a profile.
pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) {
if let Some(dir) = store_dir("Color Profiles") {
session.attach_profile_store(dir);
}
}
/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from
/// now on there. After the preferences were restored: the folder is the one place the presets live.
pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) {
if let Some(dir) = store_dir("PDF Presets") {
session.attach_pdf_preset_store(dir);
}
}
#[cfg(test)]
mod tests {
use super::*;
+3
View File
@@ -120,3 +120,6 @@ sync_github_name = false
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.theromis]
sync_github_name = true
+24 -7
View File
@@ -171,9 +171,25 @@ impl GenericCmyk {
xyz_to_lab(self.to_xyz(cmyk))
}
/// Display sRGB: media-relative (paper shows as white) or absolute (simulate paper colour).
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
xyz_to_srgb(if absolute { self.to_xyz(cmyk) } else { self.to_xyz_rel(cmyk) })
/// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour).
/// With `bpc` (media-relative only) the press black displays as black, undoing the
/// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the
/// darkest rich black shows as black again.
pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
if absolute {
return self.to_xyz(cmyk);
}
let x = self.to_xyz_rel(cmyk);
if !bpc {
return x;
}
let bp = self.black_rel;
[0, 1, 2].map(|i| ((x[i] - bp[i]) / (1.0 - bp[i] / D50[i])).max(0.0))
}
/// Display sRGB, see [`Self::display_xyz`].
pub fn to_srgb(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc))
}
/// Lab of the darkest printable black (media-relative).
@@ -425,11 +441,12 @@ impl GenericCmyk {
self.from_lab(srgb_to_lab(rgb), intent, bpc)
}
/// ΔE2000 between an sRGB colour and its best (relative colorimetric) reproduction.
pub fn gamut_error(&self, src: Lab) -> f32 {
let target = self.gamut_map(src, Intent::RelativeColorimetric, false);
/// ΔE2000 between a colour and its best (relative colorimetric) reproduction as it displays
/// (with `bpc`, the press black showing as black).
pub fn gamut_error(&self, src: Lab, bpc: bool) -> f32 {
let target = self.gamut_map(src, Intent::RelativeColorimetric, bpc);
let cmyk = self.separate(target);
delta_e2000(self.to_lab_rel(cmyk), target)
delta_e2000(xyz_to_lab(self.display_xyz(cmyk, false, bpc)), src)
}
}
+35 -10
View File
@@ -257,9 +257,25 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
Ok(info)
}
/// A number that changes whenever [`register_icc`] registers a profile: conversions through a
/// profile looked up by name, such as those of a [`ProofTarget::Cmyk`] proof, give the same colors
/// as long as the number stays the same.
/// Forget a profile [`register_icc`] registered (by its name); `false` when there is none of that
/// name (the built-in profiles can't be removed). Settings that name it no longer resolve, so a
/// caller checks [`active_settings`] first.
pub fn unregister_icc(name: &str) -> bool {
let mut u = USER.write().unwrap_or_else(|e| e.into_inner());
let before = u.len();
u.retain(|x| x.name != name);
let removed = u.len() != before;
drop(u);
if removed {
clear_proof_cache();
PROFILES_GENERATION.fetch_add(1, Ordering::Relaxed);
}
removed
}
/// A number that changes whenever [`register_icc`] registers a profile or [`unregister_icc`]
/// removes one: conversions through a profile looked up by name, such as those of a
/// [`ProofTarget::Cmyk`] proof, give the same colors as long as the number stays the same.
pub fn profiles_generation() -> u64 {
PROFILES_GENERATION.load(Ordering::Relaxed)
}
@@ -346,10 +362,10 @@ impl Cms {
}
}
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
match space {
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute, bpc),
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
CmykSpace::Icc(p) => {
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
@@ -376,7 +392,7 @@ impl Cms {
{
return v;
}
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc)
}
/// CMYK in the working space → working RGB with `intent`. An ICC CMYK profile converts
/// directly into the RGB profile: not through display sRGB, which would clip colours a wider
@@ -408,6 +424,15 @@ impl Cms {
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
}
}
/// Lab of working-space CMYK as it displays: [`Self::cmyk_to_lab`] plus, for the built-in
/// Generic CMYK, the black-point compensation of [`Self::cmyk_to_srgb`] (its press black shows
/// as black). Unclipped, for interpolating tables.
pub fn cmyk_display_lab(&self, cmyk: [f32; 4]) -> Lab {
match &self.cmyk {
CmykSpace::Generic(g) => lab::xyz_to_lab(g.display_xyz(cmyk.map(|v| v.clamp(0.0, 1.0)), false, self.settings.bpc)),
_ => self.cmyk_to_lab(cmyk),
}
}
/// Lab → working CMYK with `intent`.
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
match &self.cmyk {
@@ -429,7 +454,7 @@ impl Cms {
/// CIE Lab (D50) of any colour.
pub fn lab(&self, c: &Color) -> Lab {
match *c {
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
Color::Cmyk { c, m, y, k } => self.cmyk_display_lab([c, m, y, k]),
Color::Lab { l, a, b } => Lab::new(l, a, b),
_ => lab::srgb_to_lab(self.display_rgb(c)),
}
@@ -490,7 +515,7 @@ impl Cms {
Color::Rgb { .. } | Color::Lab { .. } => {
let src = self.lab(c);
match &self.cmyk {
CmykSpace::Generic(g) => g.gamut_error(src),
CmykSpace::Generic(g) => g.gamut_error(src, self.settings.bpc),
CmykSpace::Device => 0.0,
CmykSpace::Icc(_) => {
let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false);
@@ -523,7 +548,7 @@ impl Cms {
ProofTarget::Cmyk(name) => match cmyk_space(name) {
Ok(sp) => {
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc)
}
Err(_) => srgb,
},
@@ -534,7 +559,7 @@ impl Cms {
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
match &proof.target {
ProofTarget::Cmyk(name) => match cmyk_space(name) {
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc),
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
},
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
+14
View File
@@ -245,6 +245,20 @@ fn loads_system_cmyk_icc() {
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
}
/// With black-point compensation, sRGB black separates to the press's darkest black and that black
/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey).
#[test]
fn generic_cmyk_black_round_trips_with_bpc() {
let c = cms();
let cmyk = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric);
let back = c.cmyk_to_srgb(cmyk, false).map(|v| (v * 255.0).round() as u8);
assert_eq!(back, [0, 0, 0], "{cmyk:?}");
// The paper still shows as white, and without compensation the press black stays a dark grey.
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
let raw = cms_no_bpc().cmyk_to_srgb(cmyk, false);
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
}
#[test]
fn lab_colours_round_trip_through_the_cms() {
let c = cms_no_bpc();
+2 -1
View File
@@ -22,7 +22,8 @@ fn cmyk_profiles_convert_like_the_cms() {
assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk);
let p = IccProfile::from_bytes(None, &bytes).unwrap();
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk));
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap();
// The encoded profile converts without black-point compensation, so compare without it.
let cms = Cms::new(&ColorSettings { cmyk: name.into(), bpc: false, ..Default::default() }).unwrap();
for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] {
let want = cms.cmyk_to_srgb(cmyk, false);
let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap();
+12
View File
@@ -181,6 +181,10 @@ impl Color {
_ => {}
}
let h = s.strip_prefix('#').unwrap_or(s);
// `from_str_radix` accepts a leading `+`, so check the digits first.
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let p = |i: usize, n: usize| u8::from_str_radix(h.get(i..i + n)?, 16).ok();
match h.len() {
3 => Some(Color::rgb8(p(0, 1)? * 17, p(1, 1)? * 17, p(2, 1)? * 17)),
@@ -412,6 +416,14 @@ mod tests {
assert_eq!(Color::from_hex("zz"), None);
}
#[test]
fn hex_rejects_sign_characters() {
for bad in ["#+1+2+3", "+f+f+f", "#-1-2-3", "#+12+34+56", "#+f+f+f+f", "#12345+"] {
assert_eq!(Color::from_hex(bad), None, "{bad}");
}
assert_eq!(Color::from_hex("#123"), Color::from_hex("#112233"));
}
#[test]
fn hsb_roundtrip() {
for hex in ["#ff0000", "#00ff00", "#0000ff", "#336699", "#ffffff", "#000000", "#c0ffee"] {
+142
View File
@@ -0,0 +1,142 @@
//! Cooperative cancellation and progress for long-running work (exports, flattening, baking).
//!
//! A host that wants to be able to stop a call (the MCP server, on `notifications/cancelled`)
//! runs it inside [`scope`] with a [`CancelToken`]. Code deep below, which has no handle on the
//! host, calls [`check`] (or [`is_cancelled`] where it can't return an error) at points where
//! stopping is safe, and returns [`Cancelled`] up through the same `Result`s it already has. The
//! token is tied to the thread that entered the scope, so concurrent sessions on other threads
//! (and tests running in parallel) never see each other's cancellation, and code outside any
//! scope is never cancelled.
//!
//! Work that stops early leaves nothing behind: callers drop whatever was half built and report
//! the error, so a cancelled command changes nothing.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// A flag one thread raises and another polls. Cloning shares the flag.
#[derive(Clone, Debug, Default)]
pub struct CancelToken(Arc<AtomicBool>);
impl CancelToken {
pub fn new() -> Self {
Self::default()
}
/// Ask for the work holding this token to stop.
pub fn cancel(&self) {
self.0.store(true, Ordering::Relaxed);
}
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
/// The error of work that stopped because it was cancelled.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Cancelled;
impl std::fmt::Display for Cancelled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("cancelled")
}
}
impl std::error::Error for Cancelled {}
/// Receives progress: `(done, total, what is being done)`. `total` is 0 when it isn't known.
pub type ProgressSink = Rc<dyn Fn(u64, u64, &str)>;
struct Scope {
token: CancelToken,
sink: Option<ProgressSink>,
}
thread_local! {
static CURRENT: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// Puts the previous scope back, also when the work inside unwinds.
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let previous = self.0.take();
// `try_with`: during thread teardown the slot may be gone; nothing to restore then.
let _ = CURRENT.try_with(|c| *c.borrow_mut() = previous);
}
}
/// Run `f` with `token` (and an optional progress `sink`) in effect on this thread.
pub fn scope<T>(token: &CancelToken, sink: Option<ProgressSink>, f: impl FnOnce() -> T) -> T {
let previous = CURRENT.with(|c| c.borrow_mut().replace(Scope { token: token.clone(), sink }));
let _restore = Restore(previous);
f()
}
/// Has the work running on this thread been cancelled? Always false outside a [`scope`].
pub fn is_cancelled() -> bool {
CURRENT.with(|c| c.borrow().as_ref().is_some_and(|s| s.token.is_cancelled()))
}
/// Is this thread running inside a [`scope`], so that a cancel can reach it?
pub fn is_cancellable() -> bool {
CURRENT.with(|c| c.borrow().is_some())
}
/// `Err(Cancelled)` when the work running on this thread has been cancelled.
pub fn check() -> Result<(), Cancelled> {
if is_cancelled() { Err(Cancelled) } else { Ok(()) }
}
/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`]
/// that has a sink.
pub fn progress(done: u64, total: u64, what: &str) {
// The sink runs outside the borrow, so it may itself call back into this module.
let sink = CURRENT.with(|c| c.borrow().as_ref().and_then(|s| s.sink.clone()));
if let Some(sink) = sink {
sink(done, total, what);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_work_inside_the_scope_is_cancelled() {
let t = CancelToken::new();
assert_eq!(check(), Ok(()));
scope(&t, None, || {
assert_eq!(check(), Ok(()));
t.cancel();
assert_eq!(check(), Err(Cancelled));
// A nested scope with its own token shadows, then restores.
scope(&CancelToken::new(), None, || assert_eq!(check(), Ok(())));
assert!(is_cancelled());
});
assert!(!is_cancelled(), "the scope ended");
}
#[test]
fn progress_reaches_the_sink_and_the_scope_survives_a_panic() {
let seen = Rc::new(RefCell::new(vec![]));
let sink: ProgressSink = {
let seen = seen.clone();
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
};
let t = CancelToken::new();
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
scope(&t, Some(sink), || {
progress(1, 3, "page");
panic!("boom");
})
}));
assert!(r.is_err());
progress(2, 3, "after");
assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]);
}
}
+203 -34
View File
@@ -2,10 +2,12 @@
//!
//! Two encodings hold them: a four-component JPEG, whose samples are stored inverted with Adobe's
//! marker (the convention JPEG readers and PDF writers take a CMYK JPEG to follow), and a CMYK
//! TIFF (8 or 16 bits, chunky, ink amounts as they are). [`ImageBlob::cmyk_tiff`] writes the
//! latter, Deflate-compressed. Every other image decodes to RGB(A) only.
//! TIFF (8 or 16 bits, ink amounts as they are, each pixel's inks together or one plane per ink).
//! [`ImageBlob::cmyk_tiff`] writes the latter, inks together, Deflate-compressed. Every other image
//! decodes to RGB(A) only.
use std::io::Cursor;
use std::sync::Arc;
use zune_jpeg::zune_core::bytestream::ZCursor;
use zune_jpeg::zune_core::colorspace::ColorSpace;
@@ -54,25 +56,136 @@ fn is_tiff(bytes: &[u8]) -> bool {
bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*")
}
/// The colour type of TIFF `bytes` and its decoder.
/// The width and height of the first image of TIFF `bytes` when it has more strips or tiles than
/// the `tiff` decoder can count: strips per plane times planes past `u32::MAX`, or tiles per plane
/// times planes past `usize::MAX`. A file that stores one plane per channel (PlanarConfiguration 2)
/// has a plane for each channel; other files have one. The decoder multiplies these counts without
/// a check, which panics where overflow checks are on. Such an image, when it is at least a pixel
/// wide, claims at least 2^32 samples.
/// Where a tag repeats, the value giving the most strips or tiles counts. `None` for other images
/// and for bytes that aren't a TIFF.
pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
let le = bytes.starts_with(b"II*\0");
if !le && !bytes.starts_with(b"MM\0*") {
return None;
}
let u16_at = |at: usize| -> Option<u16> {
let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?;
Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) })
};
let u32_at = |at: usize| -> Option<u32> {
let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?;
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
};
let ifd = usize::try_from(u32_at(4)?).ok()?;
// (tag, the entry's single BYTE, SHORT, LONG or IFD value, which sits in the entry, or its
// single LONG8 or IFD8 value that fits in 32 bits, which sits where the entry points)
let entry = |i: usize| -> Option<(u16, Option<u32>)> {
let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?;
let (ty, count, at) = (u16_at(e.checked_add(2)?)?, u32_at(e.checked_add(4)?)?, e.checked_add(8)?);
let value = match (ty, count) {
(1, 1) => bytes.get(at).map(|&b| u32::from(b)),
(3, 1) => u16_at(at).map(u32::from),
(4 | 13, 1) => u32_at(at),
(16 | 18, 1) => {
let at = usize::try_from(u32_at(at)?).ok()?;
let (lo, hi) = (u32_at(at)?, u32_at(at.checked_add(4)?)?);
let wide = if le { u64::from(hi) << 32 | u64::from(lo) } else { u64::from(lo) << 32 | u64::from(hi) };
u32::try_from(wide).ok()
}
_ => None,
};
Some((u16_at(e)?, value))
};
let (mut width, mut height, mut planes, mut planar) = (0u32, 0u32, 1u32, false);
let (mut rows, mut tile_w, mut tile_h) = (None::<u32>, None::<u32>, None::<u32>);
let least = |was: Option<u32>, v: u32| Some(was.map_or(v, |w| w.min(v)));
for i in 0..usize::from(u16_at(ifd)?) {
let Some((tag, value)) = entry(i) else { break };
let Some(v) = value else { continue };
match tag {
256 => width = width.max(v),
257 => height = height.max(v),
277 => planes = planes.max(v),
278 => rows = least(rows, v),
284 => planar |= v == 2,
322 => tile_w = least(tile_w, v),
323 => tile_h = least(tile_h, v),
_ => {}
}
}
let planes = if planar { u128::from(planes) } else { 1 };
let tiles = match (tile_w, tile_h) {
(Some(tw @ 1..), Some(th @ 1..)) => u128::from(width.div_ceil(tw)) * u128::from(height.div_ceil(th)) * planes > usize::MAX as u128,
_ => false,
};
let rows = rows.unwrap_or(height);
let strips = rows > 0 && u128::from(height.saturating_sub(1) / rows + 1) * planes > u128::from(u32::MAX);
(tiles || strips).then_some((width, height))
}
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
if !is_tiff(bytes) {
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
return None;
}
let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?;
Some((d.colortype().ok()?, d))
}
/// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)?
fn planar(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>) -> bool {
d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1)
}
/// The samples of the `n` pixels of the TIFF `d` reads, `per` to a pixel (the number its color
/// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file
/// stores each pixel's samples together or one plane per channel. `None` when they don't decode to
/// that many samples, which includes images whose planes together need more than the decoder's
/// limit (256 MB of samples).
fn read_samples(d: &mut tiff::decoder::Decoder<Cursor<&[u8]>>, n: usize, per: usize) -> Option<Vec<u8>> {
let mut buf = tiff::decoder::DecodingResult::U8(Vec::new());
let layout = d.read_image_to_buffer(&mut buf).ok()?;
let data = match buf {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
if Some(data.len()) != n.checked_mul(per) {
return None;
}
if layout.planes <= 1 {
return Some(data);
}
if layout.planes != per {
return None;
}
let planes: Vec<&[u8]> = data.chunks_exact(n).collect();
Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect())
}
impl ImageBlob {
/// A CMYK TIFF of ink amounts `inks` (Deflate-compressed); `None` when it can't be written.
pub fn cmyk_tiff(inks: &Inks) -> Option<Self> {
use tiff::encoder::{Compression, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
Self::cmyk_tiff_at(inks, None)
}
/// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per
/// inch) when it is given and both are finite and above 0.
pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option<Self> {
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel};
use tiff::tags::{ResolutionUnit, Tag};
let mut out = Cursor::new(Vec::new());
TiffEncoder::new(&mut out)
.ok()?
.with_compression(Compression::Deflate(DeflateLevel::Balanced))
.write_image::<CMYK8>(inks.width, inks.height, &inks.data)
.ok()?;
let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced));
let mut img = enc.new_image::<CMYK8>(inks.width, inks.height).ok()?;
if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) {
// Ten-thousandths of a pixel per inch.
let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 };
img.encoder().write_tag(Tag::XResolution, r(x)).ok()?;
img.encoder().write_tag(Tag::YResolution, r(y)).ok()?;
img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?;
}
img.write_data(&inks.data).ok()?;
Some(Self::new("image/tiff", out.into_inner()))
}
@@ -119,34 +232,42 @@ fn jpeg_inks(bytes: &[u8]) -> Option<Inks> {
Inks::new(w, h, data)
}
/// The ink amounts of a chunky CMYK TIFF (16-bit samples to 8 bits).
/// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane
/// per ink.
fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
let (kind, mut d) = tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
// Planar samples (one plane per ink) would read as interleaved.
let planar = d.find_tag_unsigned::<u16>(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) || planar || pixels(w, h).is_none() {
if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) {
return None;
}
let data = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
Inks::new(w, h, data)
let n = usize::try_from(pixels(w, h)?).ok()?;
Inks::new(w, h, read_samples(&mut d, n, 4)?)
}
/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per
/// ink, which the image decoder doesn't read, their ink amounts written again by
/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK
/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read.
pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option<Vec<u8>> {
let inks = tiff_inks(bytes)?;
let (_, mut d) = tiff(bytes)?;
if !planar(&mut d) {
return Some(bytes.to_vec());
}
ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes))
}
/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at
/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink
/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB),
/// and grayscale with extra samples, of which the first is the alpha when the file says so (other
/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other
/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`].
/// channels are left out). Associated (premultiplied) alpha is divided back out. The samples may be
/// stored together or one plane per channel. `None` for other images and ones larger than
/// [`MAX_CMYK_PIXELS`].
pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option<image::RgbaImage> {
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
let (w, h) = d.dimensions().ok()?;
let planar = d.find_tag_unsigned::<u16>(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1);
// 0: white is zero, 1: black is zero (the two grayscales).
let photometric = d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten();
// ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha.
@@ -159,18 +280,8 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
}
_ => return None,
};
if planar {
return None;
}
let n = usize::try_from(pixels(w, h)?).ok()?;
let data: Vec<u8> = match d.read_image().ok()? {
tiff::decoder::DecodingResult::U8(v) => v,
tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(),
_ => return None,
};
if Some(data.len()) != n.checked_mul(samples) {
return None;
}
let data = read_samples(&mut d, n, samples)?;
let mut out = Vec::with_capacity(n * 4);
for p in data.chunks_exact(samples) {
let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX };
@@ -246,6 +357,64 @@ mod tests {
assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque");
}
/// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel,
/// times 2 planes.
#[test]
fn tiffs_with_more_tiles_than_can_be_counted_are_oversized() {
let header = |planar: u32| {
let mut f = b"II*\0\x08\0\0\0".to_vec();
let entries = [(256u16, u32::MAX), (257, u32::MAX), (258, 8), (262, 1), (277, 2), (284, planar), (322, 1), (323, 1), (324, 0), (325, 0)];
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
for (tag, v) in entries {
f.extend([tag.to_le_bytes(), 4u16.to_le_bytes()].concat());
f.extend([1u32.to_le_bytes(), v.to_le_bytes()].concat());
}
f.extend([0; 4]);
f
};
assert_eq!(oversized_tiff(&header(2)), Some((u32::MAX, u32::MAX)));
assert_eq!(oversized_tiff(&header(1)), None, "one plane: (2^32 − 1)² tiles can be counted in 64 bits");
assert!(tiff(&header(2)).is_none(), "not decoded");
}
/// A header claiming more strips than can be counted: 2^31 + 1 rows, a strip each, times 2
/// planes. The decoder counts strips when the file has strip offsets, tile sizes or not, and
/// reads a LONG8 value from where the entry points.
#[test]
fn tiffs_with_more_strips_than_can_be_counted_are_oversized() {
// Each entry holds one value of type 3 (SHORT), 4 (LONG) or 16 (LONG8, after the directory).
let header = |entries: &[(u16, u16, u32)]| {
let after = 8 + 2 + 12 * entries.len() + 4;
let mut f = b"II*\0\x08\0\0\0".to_vec();
f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
let mut wide = vec![];
for &(tag, ty, v) in entries {
f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat());
f.extend(1u32.to_le_bytes());
if ty == 16 {
f.extend(u32::try_from(after + wide.len()).unwrap().to_le_bytes());
wide.extend(u64::from(v).to_le_bytes());
} else {
f.extend(v.to_le_bytes());
}
}
f.extend([0; 4]);
f.extend(wide);
f
};
let tall = 1u32 << 31 | 1;
let strips = [(256, 4, 1), (257, 4, tall), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
assert_eq!(oversized_tiff(&header(&strips)), Some((1, tall)));
let tile_sizes = [&strips[..], &[(322, 4, u32::MAX), (323, 4, u32::MAX)]].concat();
assert_eq!(oversized_tiff(&header(&tile_sizes)), Some((1, tall)), "tile sizes without tile offsets");
let mut long8 = strips;
long8[1] = (257, 16, tall);
assert_eq!(oversized_tiff(&header(&long8)), Some((1, tall)), "ImageLength as a LONG8");
for h in [header(&tile_sizes), header(&long8)] {
assert!(tiff(&h).is_none(), "not decoded");
}
}
fn inks() -> Inks {
Inks::new(3, 2, vec![0, 40, 100, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 12, 34, 56, 78]).unwrap()
}
+11 -2
View File
@@ -5,7 +5,7 @@
use std::borrow::Cow;
use std::collections::BTreeSet;
use vectorcraft_geom::corners::{Corner, cut_corners, path_corners};
use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, PathData, Rect};
@@ -23,6 +23,8 @@ pub struct LiveCorners<'a> {
/// The corners that can be cut, in anchor order ([`path_corners`] of `base`).
pub corners: Vec<Corner>,
radii: Vec<f64>,
/// The radius each of `corners` is drawn with ([`fitted_radii`]).
drawn: Vec<f64>,
kinds: Vec<CornerKind>,
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
sources: Vec<Vec<usize>>,
@@ -54,7 +56,9 @@ impl<'a> LiveCorners<'a> {
})
.collect()
};
Some(Self { corners: path_corners(&base), base, xf, radii, kinds, sources })
let corners = path_corners(&base);
let drawn = fitted_radii(&corners, &radii);
Some(Self { corners, base, xf, radii, drawn, kinds, sources })
}
/// The Live Corners of a path object.
@@ -73,6 +77,11 @@ impl<'a> LiveCorners<'a> {
self.radii.get(index).copied().unwrap_or(0.0)
}
/// The radius corner `index` is drawn with, in document units: its own, no larger than its
/// neighbours leave room for (0 for an anchor that is no corner).
pub fn drawn_radius(&self, index: usize) -> f64 {
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.drawn.get(i)).copied().unwrap_or(0.0)
}
/// The kind corner `index` is cut with.
pub fn kind(&self, index: usize) -> CornerKind {
self.kinds.get(index).copied().unwrap_or_default()
+12 -7
View File
@@ -430,17 +430,22 @@ impl GraphSpec {
let cells = self.cells();
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len());
let mut out = String::from(",");
out.push_str(&(0..width).map(|s| self.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
// Quote a label holding a separator, quote or line break (inner quotes doubled), or one with
// surrounding spaces; `always` also quotes blank and numeric labels so they stay labels.
let field = |c: &str, always: bool| {
let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim();
if special || (always && (c.trim().is_empty() || c.trim().parse::<f64>().is_ok())) {
format!("\"{}\"", c.replace('"', "\"\""))
} else {
c.to_string()
}
};
out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::<Vec<_>>().join(","));
out.push('\n');
let labelled = !self.categories.is_empty();
for (i, r) in cells.iter().enumerate() {
if labelled {
let c = self.categories.get(i).map(String::as_str).unwrap_or_default();
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
out.push_str(&format!("\"{c}\""));
} else {
out.push_str(c);
}
out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true));
out.push(',');
}
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
+1
View File
@@ -8,6 +8,7 @@
pub mod appearance;
pub mod assets;
pub mod blend;
pub mod cancel;
pub mod clipnest;
pub mod cmyk;
pub mod corners;
+9 -8
View File
@@ -94,26 +94,27 @@ impl Node {
}
}
/// Draw the overprinting fills and strokes of `a`'s subtree with Multiply (those with a blend
/// Draw the overprinting fills and strokes of `a`'s subtree with Darken (those with a blend
/// mode of their own keep it), copying only the nodes on the way to them: on screen and on a
/// printed plate this approximates their inks printing over the inks below. With `discard_white`
/// printed plate this approximates their inks printing over the inks below. Darken keeps the
/// stronger ink of each plate (and each additive channel), so a colour overprinting itself stays
/// that colour, where Multiply would darken it. With `discard_white`
/// (Document Setup → Discard White Overprint) white fills and strokes knock out instead.
pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
pub fn darken_overprints(a: &mut Arc<Node>, discard_white: bool) {
if !a.has_overprint() {
return;
}
let n = Arc::make_mut(a);
let multiplies =
|overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
let darkens = |overprint: bool, blend: BlendMode, paint: &Paint| overprint && blend == BlendMode::Normal && !(discard_white && is_white(paint));
for it in &mut n.appearance.items {
match it {
crate::AppearanceItem::Fill(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
crate::AppearanceItem::Stroke(l) if multiplies(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Multiply,
crate::AppearanceItem::Fill(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
crate::AppearanceItem::Stroke(l) if darkens(l.overprint, l.blend, &l.paint) => l.blend = BlendMode::Darken,
_ => {}
}
}
for c in n.children_mut().into_iter().flatten() {
multiply_overprints(c, discard_white);
darken_overprints(c, discard_white);
}
}
+71 -5
View File
@@ -11,15 +11,20 @@ use crate::ImageBlob;
/// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB.
pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded or would take
/// more than `max` bytes as RGBA. The size is read from the header first: the decoder's own limit
/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717).
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded, would take
/// more than `max` bytes as RGBA, or need more than `max` bytes to decode. The size is read from
/// the header first: the decoder's limit is on the file's samples, and a gray image is a quarter
/// of what it becomes here (#1717).
fn rgba_within(bytes: &[u8], max: u64) -> Option<image::RgbaImage> {
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) * 4 > max {
if u64::from(w) * u64::from(h) > max / 4 {
return None;
}
Some(image::load_from_memory(bytes).ok()?.to_rgba8())
let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(max);
reader.limits(limits);
Some(reader.decode().ok()?.into_rgba8())
}
impl ImageBlob {
@@ -131,6 +136,35 @@ mod tests {
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
}
/// A GIF whose `w` × `h` screen holds one opaque red pixel at the top left. The file holds no
/// other pixels, and the decoder makes them transparent.
fn gif_with_one_red_pixel(w: u16, h: u16) -> Vec<u8> {
let mut gif = b"GIF89a".to_vec();
gif.extend_from_slice(&w.to_le_bytes());
gif.extend_from_slice(&h.to_le_bytes());
// Flags for a global color table of two colors, the background and aspect bytes, then the
// table: red and black.
gif.extend_from_slice(&[0x80, 0, 0, 255, 0, 0, 0, 0, 0]);
// A 1 × 1 image at (0, 0), its LZW data (clear, color 0, end), and the end of the file.
gif.extend_from_slice(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0, 0x3B]);
gif
}
/// The pixels are decoded within the allowance, not within the decoder's own 512 MB: an RGBA
/// image of 11,586 × 11,586 pixels (536,941,584 bytes) is recolored and sampled.
#[test]
fn pixels_past_the_decoders_own_limit_are_decoded_within_the_allowance() {
let (w, h) = (11_586, 11_586);
let rgba = u64::from(w) * u64::from(h) * 4;
assert!(rgba > 512 << 20);
let gif = gif_with_one_red_pixel(w, h);
assert!(rgba_within(&gif, rgba - 1).is_none(), "past the allowance");
let b = ImageBlob::new("image/gif", gif);
assert!(b.map_rgb(|[r, g, bl]| [bl, g, r]).is_some(), "red becomes blue");
assert_eq!(b.sample(0.5, 0.5, 1), Some([255, 0, 0, 255]));
assert_eq!(b.sample(f64::from(w) - 0.5, f64::from(h) - 0.5, 1), None, "transparent");
}
/// #1717: the RGBA size is checked from the header, before anything is decoded: a grey image
/// the decoder's own limit lets through is four times that as RGBA.
#[test]
@@ -143,6 +177,38 @@ mod tests {
assert!(rgba_within(b"not an image", u64::MAX).is_none());
}
/// A little-endian TIFF whose header gives `w` × `h` 8-bit gray pixels in one strip. The file holds
/// no pixel data.
fn tiff_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"II*\0".to_vec();
out.extend(8u32.to_le_bytes());
// Each entry is (tag, type: 3 SHORT or 4 LONG, value) and holds one value.
let entries: [(u16, u16, u32); 9] =
[(256, 4, w), (257, 4, h), (258, 3, 8), (259, 3, 1), (262, 3, 1), (273, 4, 8), (277, 3, 1), (278, 4, h), (279, 4, 0)];
out.extend((entries.len() as u16).to_le_bytes());
for (tag, kind, value) in entries {
out.extend(tag.to_le_bytes());
out.extend(kind.to_le_bytes());
out.extend(1u32.to_le_bytes());
out.extend(value.to_le_bytes());
}
out.extend(0u32.to_le_bytes());
out
}
/// A TIFF's sides go up to 2^32 - 1 pixels: a size whose RGBA bytes don't fit in 64 bits is
/// refused like any other past the allowance, and an image within the allowance still decodes.
#[test]
fn a_size_past_64_bits_as_rgba_is_not_decoded() {
let huge = tiff_header(1 << 31, 1 << 31);
assert!(rgba_within(&huge, MAX_RASTER_ALLOC).is_none());
assert!(rgba_within(&huge, u64::MAX).is_none());
let gray = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap();
let mut png = Vec::new();
gray.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert_eq!(rgba_within(&png, 35).map(|i| i.dimensions()), Some((4, 2)), "32 bytes as RGBA, within 35");
}
#[test]
fn samples_a_pixel_or_the_average_around_it() {
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
+17 -12
View File
@@ -27,7 +27,8 @@ pub fn to_object_name(id: &str) -> String {
out
}
/// The XML id for an object name (the inverse of [`to_object_name`]).
/// The XML id for an object name (the inverse of [`to_object_name`]). The empty name stays empty,
/// since a bare `_` would read back as a space.
pub fn from_object_name(name: &str) -> String {
let mut out = String::with_capacity(name.len().saturating_mul(2));
for (i, c) in name.chars().enumerate() {
@@ -35,17 +36,14 @@ pub fn from_object_name(name: &str) -> String {
out.push('_');
} else if c == '_' {
out.push_str("_x5F_");
} else if i == 0 && c.is_ascii_digit() {
} else if (i == 0 && !(c.is_alphabetic() || c == ':')) || !(c.is_alphanumeric() || c == '.' || c == '-' || c == ':') {
// A first character that cannot start an XML name is escaped, never given a bare `_`
// prefix (that would read back as a space).
out.push_str(&format!("_x{:X}_", u32::from(c)));
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
out.push(c);
} else {
out.push_str(&format!("_x{:X}_", u32::from(c)));
out.push(c);
}
}
if out.is_empty() || !starts_name(&out) {
out.insert(0, '_');
}
out
}
@@ -66,10 +64,6 @@ pub fn is_valid(id: &str) -> bool {
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
}
fn starts_name(id: &str) -> bool {
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
@@ -114,4 +108,15 @@ mod tests {
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
}
#[test]
fn round_trip_names_that_cannot_start_an_xml_name() {
for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] {
let id = from_object_name(name);
assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}");
}
assert_eq!(from_object_name("-abc"), "_x2D_abc");
assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm");
assert_eq!(from_object_name("²x"), "_xB2_x");
}
}
+77 -1
View File
@@ -89,7 +89,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind {
/// `n` with its geometry effects evaluated (`None` = unchanged).
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if !needs_bake(n) {
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() {
return None;
}
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
@@ -120,6 +121,13 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
let m = reshape(n, symbol.as_deref())?;
return Some(bake_node(d, &m).unwrap_or(m));
}
// A path whose Transform effect copies it with a gradient: each copy an object of its own, so
// its gradient fits it rather than spanning every copy.
if crate::has_gradient_copies(n)
&& let Some(m) = reshape(n, None)
{
return Some(bake_pieces(d, m));
}
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that
// Transform effects only move stays type). The new pieces (results, outlines, copies, the
// fills' and strokes' art) get ids of their own.
@@ -572,6 +580,15 @@ mod tests {
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
fn skip_without_craft_fonts() -> bool {
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
if skip {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
}
skip
}
fn fx(id: &str, params: serde_json::Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
@@ -647,6 +664,9 @@ mod tests {
#[test]
fn type_moved_or_copied_by_a_groups_transform_effect_stays_type() {
if skip_without_craft_fonts() {
return;
}
for (params, origins, square) in [
(json!({"moveH": 30}), vec![(50.0, 60.0)], Rect::new(30.0, 0.0, 40.0, 10.0)),
(json!({"moveH": 200, "copies": 1}), vec![(20.0, 60.0), (220.0, 60.0)], Rect::new(0.0, 0.0, 210.0, 10.0)),
@@ -678,6 +698,9 @@ mod tests {
#[test]
fn hidden_type_in_a_group_that_keeps_its_type_stays_hidden() {
if skip_without_craft_fonts() {
return;
}
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
let mut hidden = type_node(55);
hidden.visible = false;
@@ -689,6 +712,9 @@ mod tests {
#[test]
fn type_outlined_next_to_type_that_stays_type_keeps_its_look() {
if skip_without_craft_fonts() {
return;
}
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
let mut shadowed = type_node(55);
shadowed.opacity = 0.5;
@@ -711,6 +737,9 @@ mod tests {
#[test]
fn type_in_a_clipping_group_with_a_fill_of_its_own_is_outlined_plain() {
if skip_without_craft_fonts() {
return;
}
let mut g = type_group(vec![fx("distort.transform", json!({"moveH": 200, "copies": 1}))]);
// The clipping path is a group with a fill of its own, holding type with an effect of its own.
let mut moved = type_node(60);
@@ -729,6 +758,9 @@ mod tests {
#[test]
fn type_with_its_own_transform_effect_copies_stays_type() {
if skip_without_craft_fonts() {
return;
}
let mut n = type_node(50);
n.opacity = 0.5;
n.appearance.effects = vec![fx("distort.transform", json!({"moveV": 50, "copies": 2})), fx("stylize.dropShadow", json!({}))];
@@ -775,6 +807,9 @@ mod tests {
#[test]
fn type_whose_copies_paint_over_each_other_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
assert_eq!(type_origins(&bake_document(&doc_with(type_group(moves.clone()))).unwrap()).len(), 2);
// A 20 pt stroke on the characters reaches the copy below.
@@ -790,6 +825,9 @@ mod tests {
#[test]
fn type_with_a_look_of_its_own_in_a_moved_group_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let moves = vec![fx("distort.transform", json!({"moveV": 40, "copies": 1}))];
let mut translucent = type_node(50);
translucent.opacity = 0.5;
@@ -862,6 +900,9 @@ mod tests {
#[test]
fn type_whose_copies_hold_too_much_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let spaced = |spaces: usize| {
let mut n = type_node(50);
if let NodeKind::Text(t) = &mut n.kind {
@@ -899,6 +940,9 @@ mod tests {
#[test]
fn type_in_a_pathfinder_group_inside_a_moved_group_is_still_outlined() {
if skip_without_craft_fonts() {
return;
}
let mut inner = type_group(vec![fx("pathfinder.add", json!({}))]);
inner.id = NodeId(53);
let mut outer = Node::group(NodeId(54), vec![Arc::new(inner)]);
@@ -932,4 +976,36 @@ mod tests {
// An effect that also scales.
assert!(transform_offsets(&[json!({"moveH": 10, "scaleH": 50})], &square).is_none());
}
/// A 10 pt square painted with a red to blue gradient and a Transform effect with 2 copies.
fn gradient_copies(id: u64) -> Node {
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 paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: 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));
n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2})));
n
}
/// The paths under `n` that paint a gradient fitted to their own 10 pt box, left to right.
fn gradient_pieces(n: &Node) -> Vec<f64> {
let mut out = vec![];
let mut walk = vec![n];
while let Some(n) = walk.pop() {
match n.children() {
Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => walk.extend(ch.iter().map(|c| &**c)),
_ => {
assert!(matches!(n.appearance.fill_paint(), Paint::Gradient(_)), "{n:?}");
out.extend(n.geometric_bounds().map(|b| b.width()));
}
}
}
out
}
#[test]
fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() {
let d = bake_document(&doc_with(gradient_copies(50))).unwrap();
// Three squares of their own, not one path spanning all three.
assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]);
}
}
+32 -10
View File
@@ -267,8 +267,20 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4;
/// A Zig Zag ridge: where it sits, the path's unit direction there, and the length along the
/// path to the ridges before and after it (zero at an open path's ends).
struct Ridge {
p: Point,
dir: Vec2,
gap_in: f64,
gap_out: f64,
}
/// Zig Zag: `ridges` extra points per segment, evenly spaced along its length and alternately
/// displaced along the normal.
/// displaced along the normal. Corner points join them with straight lines. Smooth points make a
/// wave: every peak and valley is a smooth point whose handles run along the path, a third of the
/// way to the next ridge, so each span between them follows a half cosine (an S through the path)
/// whatever the size of the ridges (#2343).
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
@@ -277,29 +289,33 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let mut subs = vec![];
for sp in &path.subpaths {
let segs = sp.segment_count();
let mut pts = vec![];
let gap = |i: usize| sp.segment(i).arclen(ZIGZAG_ARCLEN_ACCURACY) / (ridges + 1) as f64;
let mut pts: Vec<Ridge> = vec![];
let mut k = 0usize;
for i in 0..segs {
let c = sp.segment(i);
// Ridges are spaced by length, not by `t`: a line's retracted handles bunch `t` up
// near the anchors, and so does any uneven curve.
let len = c.arclen(ZIGZAG_ARCLEN_ACCURACY);
let step = len / (ridges + 1) as f64;
for j in 0..=ridges {
let s = j as f64 / (ridges + 1) as f64;
let t = if j > 0 && len > 1e-9 { c.inv_arclen(s * len, ZIGZAG_ARCLEN_ACCURACY).clamp(0.0, 1.0) } else { s };
let mut gap_in = step;
let nrm = if j == 0 {
// Average the normals of the segments meeting at the anchor.
let prev = if i > 0 {
Some(sp.segment(i - 1))
Some(i - 1)
} else if sp.closed {
Some(sp.segment(segs - 1))
Some(segs - 1)
} else {
None
};
gap_in = prev.map_or(0.0, gap);
let n1 = normal_at(&c, 0.0);
match prev {
Some(pc) => {
let s = n1 + normal_at(&pc, 1.0);
Some(pi) => {
let s = n1 + normal_at(&sp.segment(pi), 1.0);
if s.hypot() > 1e-9 { s / s.hypot() } else { n1 }
}
None => n1,
@@ -308,7 +324,7 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
normal_at(&c, t)
};
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
pts.push(c.eval(t) + nrm * (amp * sign));
pts.push(Ridge { p: c.eval(t) + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in, gap_out: step });
k += 1;
}
}
@@ -316,12 +332,18 @@ pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
if segs > 0 {
let c = sp.segment(segs - 1);
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
pts.push(c.p3 + normal_at(&c, 1.0) * (amp * sign));
let nrm = normal_at(&c, 1.0);
pts.push(Ridge { p: c.p3 + nrm * (amp * sign), dir: Vec2::new(-nrm.y, nrm.x), gap_in: gap(segs - 1), gap_out: 0.0 });
} else {
pts.extend(sp.anchors.iter().map(|a| a.p));
pts.extend(sp.anchors.iter().map(|a| Ridge { p: a.p, dir: Vec2::ZERO, gap_in: 0.0, gap_out: 0.0 }));
}
}
subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) });
subs.push(if smooth {
let anchors = pts.iter().map(|r| Anchor::with_handles(r.p, r.p - r.dir * (r.gap_in / 3.0), r.p + r.dir * (r.gap_out / 3.0))).collect();
SubPath::new(anchors, sp.closed)
} else {
SubPath::polyline(&pts.iter().map(|r| r.p).collect::<Vec<_>>(), sp.closed)
});
}
PathData::new(subs)
}
+49
View File
@@ -293,6 +293,15 @@ mod tests {
use vectorcraft_doc::{CharStyle, Effect, NodeId, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
/// Type tests need glyph outlines, which only the optional craft-fonts build input provides.
fn skip_without_craft_fonts() -> bool {
let skip = vectorcraft_text::CRAFT_FONTS.is_empty();
if skip {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
}
skip
}
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
Arc::new(Node::path(
NodeId(id),
@@ -350,6 +359,9 @@ mod tests {
#[test]
fn type_changes_the_result_of_every_pathfinder_effect() {
if skip_without_craft_fonts() {
return;
}
let glyphs = crate::outline_text(&type_h()).unwrap();
let b = glyphs.geometric_bounds().unwrap();
assert!(b.x0 < 100.0 && b.x1 > 100.0 && b.y0 > 0.0 && b.y1 < 100.0, "{b:?}");
@@ -377,6 +389,9 @@ mod tests {
#[test]
fn pieces_of_type_keep_its_paint_opacity_and_blend_mode() {
if skip_without_craft_fonts() {
return;
}
let mut t = type_h();
(t.opacity, t.blend) = (0.5, BlendMode::Multiply);
let look = |p: &Arc<Node>| (p.appearance.fill_paint(), p.opacity, p.blend);
@@ -418,6 +433,9 @@ mod tests {
#[test]
fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() {
if skip_without_craft_fonts() {
return;
}
use vectorcraft_color::BlendMode;
let art = type_in_group(|t| (t.opacity, t.blend) = (0.5, BlendMode::Multiply));
assert!(art.id == NodeId(3) && !matches!(art.kind, NodeKind::Text(_)), "{art:?}");
@@ -447,6 +465,9 @@ mod tests {
#[test]
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() {
if skip_without_craft_fonts() {
return;
}
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 ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
@@ -470,4 +491,32 @@ mod tests {
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed");
}
#[test]
fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
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 ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
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 }))];
for (k, paint) in paints.into_iter().enumerate() {
let square =
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)));
let mut g = Node::group(NodeId(10), vec![square]);
let params = serde_json::json!({"moveH": 30, "copies": 2});
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
let art = evaluate_container(&g).unwrap();
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
assert_eq!(copies.len(), 3);
for (i, c) in copies.iter().enumerate() {
// Each copy's own box: an unplaced gradient fits it, a placed one moved with it.
assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6);
match c.appearance.fill_paint() {
Paint::Gradient(g) if k == 0 => assert!(g.geom.is_none(), "copy {i} fits its own box"),
Paint::Gradient(g) => assert!((g.geom.unwrap().start.x - 30.0 * i as f64).abs() < 1e-6, "copy {i}"),
p => panic!("a gradient: {p:?}"),
}
}
}
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, needs_outline, outline_art, outline_text, reshape};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid,
+66 -4
View File
@@ -32,6 +32,17 @@ pub fn needs_outline(n: &Node) -> bool {
)
}
/// Is `n` a path or compound path with a visible Transform effect that makes copies and a gradient
/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy
/// paints its gradient by itself ([`copy_with_gradients`]), as a group's copies do.
pub fn has_gradient_copies(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
&& (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient())
&& geometry_effects(n)
.iter()
.any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0)
}
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
/// visible geometry effect? Such an effect reshapes the whole outline that its fill or stroke
/// paints ([`outline_text`], which leaves inline graphics out).
@@ -127,6 +138,16 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
a.transform(*xf, false);
Node::group(n.id, vec![Arc::new(a)])
}
// The object as the one piece the copies of its Transform effect are made from.
_ if has_gradient_copies(n) => {
let mut one = n.clone();
one.opacity = 1.0;
one.blend = Default::default();
one.isolate = false;
one.extras_mut().mask = None;
one.appearance.effects.clear();
Node::group(n.id, vec![Arc::new(one)])
}
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
_ => return None,
};
@@ -171,16 +192,21 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
/// a geometry effect on a fill or stroke of its own ([`has_item_geometry`]), or has no art (a
/// symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n) {
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) {
return None;
}
as_art(n, symbol_art)
}
/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has
/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand
/// Appearance: [`reshape`]d when it has
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
/// when it has neither.
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
// A path whose Transform effect copies it with a gradient: each copy an object of its own.
if has_gradient_copies(n) {
return as_art(n, symbol_art);
}
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
return None;
@@ -268,13 +294,21 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, &params, seed));
for_each_leaf(art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// A gradient paints each copy by itself, not the one piece the copies make together.
if let Some((m, copies)) = transform
&& copies > 0
&& (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient())
{
copy_with_gradients(leaf, &path, m, copies);
return;
}
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) });
set_geometry(leaf, out, rule);
});
if e.id == "distort.transform" {
let (m, copies) = crate::distort::transform_map(bounds, &params, seed);
if let Some((m, copies)) = transform {
transform_placed(art, m, copies);
}
}
@@ -283,6 +317,34 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
}
}
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
/// more, each mapped once more by `m` than the last, as a Transform effect copies an object. Each
/// copy is an object of its own, so its gradients fit it (a placed gradient moves with it) rather
/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would.
fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) {
let each = path.anchor_count();
let mut anchors = each;
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
let mut xf = Affine::IDENTITY;
for _ in 0..copies {
anchors = anchors.saturating_add(each);
if anchors > crate::distort::MAX_COPY_ANCHORS {
break;
}
xf *= m;
let mut copy = leaf.clone();
copy.transform(xf, vectorcraft_doc::Scaling::default());
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
break;
}
placements.push(Arc::new(copy));
}
// The copies become nodes of their own under a group with the object's id and name.
let mut g = Node::group(leaf.id, placements);
g.name = leaf.name.clone();
*leaf = g;
}
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
/// order. A group inside one of them is part of it.
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
+25
View File
@@ -510,6 +510,31 @@ fn zigzag_spaces_ridges_evenly_along_a_straight_line() {
}
}
/// #2343: Smooth points make a continuous wave, not a zigzag with rounded tips. Every peak and
/// valley is a smooth point whose handles run parallel to the line, and the curve between them
/// follows a cosine, even where the ridges are much taller than they are apart.
#[test]
fn zigzag_smooth_is_a_wave() {
use vectorcraft_geom::kurbo::ParamCurve;
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false));
for size in [3.0, 30.0] {
let out = run("distort.zigZag", json!({"ridges": 9, "points": "smooth", "size": size}), &line);
let sp = &out.subpaths[0];
assert_eq!(sp.anchors.len(), 11);
for a in &sp.anchors {
assert!(close(a.h_in.y, a.p.y, 1e-9) && close(a.h_out.y, a.p.y, 1e-9), "size {size}: handles not level at {a:?}");
}
for i in 0..sp.segment_count() {
let c = sp.segment(i);
for k in 0..=20 {
let q = c.eval(k as f64 / 20.0);
let want = -size * (std::f64::consts::PI * q.x / 10.0).cos();
assert!((q.y - want).abs() < 0.03 * size, "size {size}: {q:?} off the wave ({want})");
}
}
}
}
/// #1554 on curves: a circle's ridges sit at equal angles, at the radius plus and minus the size in
/// turn all the way round.
#[test]
+6 -6
View File
@@ -80,22 +80,22 @@ pub struct Clipboard {
pub para_styles: Vec<TextStyleDef>,
/// Brushes the objects' strokes use.
pub brushes: Vec<Brush>,
/// The artboard the objects were copied from (none when they lie on no artboard): Paste on All
/// Artboards keeps their offset to it.
/// The artboard the objects were copied from (none when they lie on no artboard; the first of
/// [`Self::artboards`] when there are any): Paste on All Artboards keeps their offset to it.
pub source_artboard: Option<Rect>,
/// Per object: the name of the layer it was copied from (Paste Remembers Layers).
pub source_layers: Vec<Option<String>>,
/// The open document the objects came from ([`DocState::uid`]): pasting back into it uses its
/// own resources as they are now.
pub source_doc: Option<u64>,
/// An artboard copied with the Artboard tool (`artboard.copy`); `nodes` are then its art. Pasting
/// adds a copy of it with the art.
pub artboard: Option<vectorcraft_doc::Artboard>,
/// The artboards copied with the Artboard tool (`artboard.copy`); `nodes` are then their art.
/// Pasting adds a copy of each with the art.
pub artboards: Vec<vectorcraft_doc::Artboard>,
}
impl Clipboard {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty() && self.artboard.is_none()
self.nodes.is_empty() && self.artboards.is_empty()
}
/// Objects `roots` of `st`'s document with their resources, source artboard and layers.
+36 -4
View File
@@ -39,10 +39,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Load Color Profile…",
[],
None,
"{path: .icc/.icm file} register an ICC profile until the app quits or the `run` ends (native only; it isn't remembered, so load it again in each run that names it) → {name, kind}",
"{path: .icc/.icm file of at most 32 MB} register an ICC profile (native only). The desktop app and vectorcraft-cli keep a copy of the file in the `Color Profiles` folder next to the preferences and register it again at every start (not with VECTORCRAFT_NO_PREFS, or in a confined `mcp --automation-*-root` server: there it lasts until the process ends) → {name, kind, remembered: whether a copy was kept}",
always,
load_profile
),
cmd!(
"color.removeProfile",
"Remove Color Profile…",
[],
None,
"{name} forget a profile color.loadProfile loaded, and the copy kept of it (built-in profiles and the working spaces stay) → {removed: name}",
always,
remove_profile
),
cmd!(
"edit.assignProfile",
"Assign Profile…",
@@ -178,12 +187,35 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
}
#[cfg(not(target_arch = "wasm32"))]
fn load_profile(_: &mut Session, p: &Value) -> Result<Value> {
fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "color.loadProfile";
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
crate::file_access::check_read(path).map_err(EngineError::Other)?;
let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?;
Ok(json!({"name": info.name, "kind": info.kind}))
let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?;
let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?;
// The bytes are kept, not the path: the file may move or go away.
let remembered = s.store_profile(&info.name, &bytes);
Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered}))
}
/// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it.
fn remove_profile(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "color.removeProfile";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?;
if info.builtin {
return Err(bad(C, format!("`{name}` is a built-in profile and stays")));
}
let working = cms::active_settings();
if working.rgb == name || working.cmyk == name {
return Err(bad(C, format!("`{name}` is a working space: choose another with edit.colorSettings first")));
}
if !cms::unregister_icc(&info.name) {
return Err(bad(C, format!("no profile named `{name}`")));
}
s.unstore_profile(&info.name);
touch_all(s);
Ok(json!({"removed": info.name}))
}
#[cfg(target_arch = "wasm32")]
+2 -1
View File
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -512,6 +512,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
}
let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?;
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
let made = super::typecmd::with_bounds(s, id, made);
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
}
+45 -17
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))),
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
["Edit"],
Some("Cmd+V"),
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). An artboard copied with artboard.copy comes with its art, right of the last artboard (the other paste commands but Paste on All Artboards: where it was), its art back in the layers it came from → {ids, artboard: the new artboard's index or null, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
"{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}",
has_clipboard,
|s, p| paste(s, p, PasteMode::Offset)
),
@@ -172,6 +172,26 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
if summary { Ok(inspect::node_summary_opts(n, opts, &doc.symbol_boxes())) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
}
/// Most node ids `document.inspect {ids}` takes.
const MAX_INSPECT_IDS: usize = 10_000;
fn doc_inspect(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.inspect";
let opts = slice_opts(p, C)?;
let ids = match p.get("ids") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) if a.len() > MAX_INSPECT_IDS => return Err(bad(C, format!("`ids` takes at most {MAX_INSPECT_IDS} ids"))),
Some(_) => Some(checked_ids_param(s, p, "ids", C)?),
};
let kind = match p.get("kind") {
None | Some(Value::Null) => None,
Some(Value::String(k)) => Some(k.clone()),
Some(_) => return Err(bad(C, "`kind` must be a string")),
};
let scope = inspect::Scope { ids, kind, limit: opt_u64(p, C, "limit")?, offset: opt_u64(p, C, "offset")?.unwrap_or(0) };
Ok(inspect::document_scoped(s, opts, &scope))
}
/// `depth` / `childLimit` for a summary read: absent (or null) means no limit.
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false })
@@ -495,16 +515,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
let st = s.doc()?;
let same_doc = clip.source_doc == Some(st.uid);
let parent = st.target_parent()?;
// A copied artboard comes back with its art: right of the last artboard, or where it was
// (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art.
let board = clip
.artboard
.as_ref()
// Copied artboards come back with their art: together right of the last artboard, as they lay,
// or where they were (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the
// art.
let boards = clip
.artboards
.iter()
.map(|a| a.rect)
.reduce(|a, b| a.union(b))
.filter(|_| mode != PasteMode::AllArtboards)
.map(|a| (a, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, a.rect) } 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
// always goes back into its layers.
let remember = (st.doc.paste_remembers_layers || board.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer));
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));
// Front/back: relative to the selection (top-most / bottom-most selected object); with
// nothing selected, the top / bottom of the current layer.
let anchor = match mode {
@@ -515,7 +538,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
}
_ => None,
};
let placements: Vec<Affine> = match (mode, board) {
let placements: Vec<Affine> = match (mode, boards) {
(_, Some((_, dv))) => vec![Affine::translate(dv)],
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
@@ -528,9 +551,13 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
// In place, in front, in back: onto the active artboard when the UI names it.
_ => vec![onto_artboard(&st.doc, p, clip)],
};
let (ids, imported, artboard) = s.edit(mode.label(), |d, sel| {
let (ids, imported, artboards) = s.edit(mode.label(), |d, sel| {
let imported = clip.import_into(d, choices, same_doc);
let artboard = board.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv));
let artboards: Vec<usize> = boards
.into_iter()
.flat_map(|(all, dv)| all.iter().map(move |a| (a, dv)))
.map(|(a, dv)| super::panelcmds::push_artboard_copy(d, a, a.rect + dv))
.collect();
let mut layers: BTreeMap<&str, NodeId> = BTreeMap::new();
// Objects pasted into each parent so far (keeps their order in front and back pastes).
let mut placed: BTreeMap<Option<NodeId>, usize> = BTreeMap::new();
@@ -565,15 +592,16 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
// Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are
// right away (opening the file later would, but paste must not depend on a reopen).
super::typecmd::refresh_bounds_of(d, &new_ids);
Ok((new_ids, imported, artboard))
Ok((new_ids, imported, artboards))
})?;
// The Artboard tool takes the pasted artboard.
if let Some(i) = artboard.filter(|_| s.tool_id() == "artboard") {
s.set_tool_option("active", &json!(i));
// The Artboard tool selects the pasted artboards, the first one active.
if !artboards.is_empty() && s.tool_id() == "artboard" {
s.set_tool_option("selected", &json!(artboards));
}
Ok(json!({
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(),
"artboard": artboard,
"artboard": artboards.first(),
"artboards": artboards,
"added": imported.added,
"merged": imported.merged,
"renamed": imported.renamed,
+32 -3
View File
@@ -138,6 +138,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object"));
}
super::params::check_effect_color(C, id, &params)?;
// Crop Marks take their style from the preferences when applied.
if id == effects::CROP_MARKS && params.get("style").is_none() {
params["style"] = json!(s.crop_mark_style().id());
@@ -313,7 +314,11 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
.transpose()?;
let visible = p.get("visible").and_then(Value::as_bool);
let item = item_target(s, p, C)?;
if set_params_effects(s, p).iter().any(|e| e == GLASS) {
let edited = set_params_effects(s, p);
for effect in &edited {
super::params::check_effect_color(C, effect, &params)?;
}
if edited.iter().any(|e| e == GLASS) {
load_texture(&mut params, C)?;
}
for id in appearance_targets(s, p)? {
@@ -504,10 +509,11 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b
expand_pieces(d, &mut m, false, &mut stroke_art);
m
})
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) {
effects::expand_leaf(d, &v, &mut stroke_art)
} else {
// Type, images, symbol instances, live objects: through their outlines.
// Type, images, symbol instances, live objects (and paths whose Transform effect copies
// them with a gradient): through their outlines.
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
@@ -995,4 +1001,27 @@ mod tests {
assert_eq!(ch.len(), 1);
assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6);
}
#[test]
fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() {
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
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 ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
s.edit("gradient", |d, _| {
let n = d.node_mut(id).unwrap();
n.appearance.set_fill(ramp);
Ok(())
})
.unwrap();
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap();
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
let mut boxes = vec![];
node(&s, id).walk(&mut |n| {
if matches!(n.kind, NodeKind::Path { .. }) && matches!(n.appearance.fill_paint(), Paint::Gradient(_)) {
boxes.push(n.geometric_bounds().unwrap().width());
}
});
assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient");
}
}
+12 -3
View File
@@ -1,5 +1,5 @@
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
//! (Fill) into plain art in one undo step; blends and repeats become groups of their steps and instances. Placed documents (Object) become editable copies of the
//! (Fill) into plain art in one undo step; blends, repeats and Intertwine groups become groups of their steps, of their instances and of what they draw. Placed documents (Object) become editable copies of the
//! art they show, their resources joining the document. A linear or radial gradient fill becomes a
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand…",
["Object"],
None,
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
"{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
has_selection,
expand
),
@@ -108,6 +108,8 @@ struct Expandable {
placed: bool,
/// Repeats (Object expands them into groups of their instances).
repeats: bool,
/// Intertwine groups (Object expands them into groups of what they draw).
intertwines: bool,
}
impl Expandable {
@@ -123,6 +125,7 @@ impl Expandable {
e.blends |= matches!(c.kind, NodeKind::Blend { .. });
e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
e.repeats |= matches!(c.kind, NodeKind::Repeat(_));
e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. });
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
e.fill |= match &c.kind {
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
@@ -134,7 +137,7 @@ impl Expandable {
}
fn object(&self) -> bool {
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats
self.text || self.live || self.effects || self.envelope || self.blends || self.placed || self.repeats || self.intertwines
}
}
@@ -162,6 +165,12 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| expand_placed_under(d, &roots))?;
}
// Intertwine groups before what they hold: their objects, and the copies of the parts drawn
// over the others, expand below.
if object && e.intertwines {
let roots = selected_roots(s)?;
s.edit("Expand", |d, _| super::intertwine::expand_intertwines(d, &roots).map(|_| ()))?;
}
// Blends first: what their steps hold (type, effects, strokes, gradients) expands below.
if object && e.blends {
let roots = selected_roots(s)?;
+111 -8
View File
@@ -5,7 +5,9 @@
//! Save and Close, or Folder; Override Open / Save / Export. Cited:
//! <https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html>
use serde_json::{Value, json};
use serde_json::Value;
#[cfg(not(target_arch = "wasm32"))]
use serde_json::json;
use super::super::*;
@@ -125,6 +127,9 @@ fn batch_folder(s: &mut Session, cmd: &str, p: &Value, override_open: bool, run:
#[cfg(not(target_arch = "wasm32"))]
fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
use super::distinct_names;
use super::screens::file_part;
let st = s.doc()?;
let names: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
if names.is_empty() {
@@ -134,12 +139,16 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
return Err(bad(cmd, format!("more than {MAX_BATCH_FILES} data sets")));
}
let doc_stem = st.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem().and_then(|s| s.to_str())).unwrap_or("Untitled").to_string();
// Data set files are named File + Data Set Name (helpx, for Override Save/Export; the folder
// destination uses the same name). `file_part` writes `/`, `\` and `:` in the data set's name as
// `-`, and `distinct_names` adds `-2`, `-3`… to a name an earlier data set used.
let stems = distinct_names(names.iter().map(|name| format!("{doc_stem}_{}", file_part(name))));
let steps = filter_steps(run.steps, true, false);
let run = Run { steps: &steps, ..*run };
let mut processed = Vec::new();
let mut failed = 0usize;
for name in &names {
match process_one_dataset(s, &run, name, &doc_stem) {
for (name, stem) in names.iter().zip(&stems) {
match process_one_dataset(s, &run, name, stem) {
Ok(row) => processed.push(row),
Err(e) => {
failed += 1;
@@ -182,14 +191,12 @@ fn process_one_file(s: &mut Session, run: &Run<'_>, path: &str, stem: &str, ext:
}
#[cfg(not(target_arch = "wasm32"))]
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, doc_stem: &str) -> Result<Value> {
fn process_one_dataset(s: &mut Session, run: &Run<'_>, name: &str, stem: &str) -> Result<Value> {
s.execute("dataset.select", &json!({"name": name}))?;
// File + Data Set Name naming when overriding Save/Export (helpx).
let stem = format!("{doc_stem}_{name}");
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, &stem, "vectorcraft");
let rewritten = rewrite_paths(run.steps, run.override_save, run.override_export, run.dest_folder, stem, "vectorcraft");
let batch = s.execute("command.batch", &json!({"label": run.label, "commands": rewritten}))?;
// Data sets share one open document: write each row out but leave it open for the next.
finish_destination(s, run, &stem, "vectorcraft", "", false)?;
finish_destination(s, run, stem, "vectorcraft", "", false)?;
Ok(json!({"dataset": name, "result": batch}))
}
@@ -432,6 +439,102 @@ mod tests {
assert_eq!(written.len(), 2, "{written:?}");
}
/// The names of the files in `dir`, sorted.
fn file_names(dir: &std::path::Path) -> Vec<String> {
let mut names: Vec<String> =
std::fs::read_dir(dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect();
names.sort();
names
}
/// A new document with an empty data set for each of `names`.
fn with_data_sets(names: &[&str]) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
for name in names {
s.execute("dataset.new", &json!({"name": name})).unwrap();
}
s
}
#[test]
fn batch_keeps_data_set_files_inside_the_destination_folder() {
let root = tmp("set-names-root");
let dest = root.join("out");
// The folder that `a/../../x` would pass through on its way out of `out`.
std::fs::create_dir_all(dest.join("Untitled_a")).unwrap();
let mut s = with_data_sets(&["a/../../x", "../y"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"stopOnError": false,
"commands": [{"command": "object.unlockAll", "params": {}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["Untitled_..-y.vectorcraft", "Untitled_a", "Untitled_a-..-..-x.vectorcraft"], "{r}");
assert_eq!(file_names(&root), ["out"], "nothing is written outside the destination folder");
}
#[test]
fn batch_saves_data_sets_named_with_separators() {
let dest = tmp("set-names-sep");
let exports = tmp("set-names-sep-export");
let mut s = with_data_sets(&["a/b", "c\\d", "e:f"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"stopOnError": false,
"commands": [{"command": "object.unlockAll", "params": {}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["Untitled_a-b.vectorcraft", "Untitled_c-d.vectorcraft", "Untitled_e-f.vectorcraft"], "{r}");
// Override Export points each data set's export at the destination folder under the same name.
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destinationFolder": exports.to_string_lossy(),
"stopOnError": false,
"commands": [{"command": "document.export", "params": {"format": "svg"}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
}
#[test]
fn batch_gives_each_data_set_a_file_of_its_own() {
let dest = tmp("set-names-same");
let mut s = with_data_sets(&["a:b", "a-b", "A-B"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destination": "folder",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "object.unlockAll", "params": {}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&dest), ["Untitled_A-B-3.vectorcraft", "Untitled_a-b-2.vectorcraft", "Untitled_a-b.vectorcraft"], "{r}");
}
#[test]
fn batch_refuses_nesting_and_empty_commands() {
let mut s = Session::new();
+44 -1
View File
@@ -307,6 +307,32 @@ fn use_artboards(p: &Value) -> Result<Option<bool>> {
}
}
/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
/// contradict it). The image size limits are checked with the scale, as for an artboard.
fn page_rect(f: &Format, p: &Value) -> Result<Option<Rect>> {
let v = match p.get("rect") {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
if !f.raster {
return Err(bad(C, format!("rect applies to raster formats, not {}", f.label)));
}
if let Some(k) = ARTBOARD_PARAMS.iter().chain(["useArtboards"].iter()).find(|k| p.get(**k).is_some_and(|v| !v.is_null())) {
return Err(bad(C, format!("rect and {k} contradict each other: give the area or the artboards")));
}
let nums: Option<Vec<f64>> = v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(Value::as_f64).collect());
let Some(&[x, y, w, h]) = nums.as_deref() else {
return Err(bad(C, "rect is [x, y, width, height] in points"));
};
let (x1, y1) = (x + w, y + h);
if !(x.is_finite() && y.is_finite() && w > 0.0 && h > 0.0 && x1.is_finite() && y1.is_finite()) {
return Err(bad(C, "rect needs finite numbers and a positive width and height"));
}
Ok(Some(Rect::new(x, y, x1, y1)))
}
/// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds).
pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document {
with_single_artboard(doc.clone(), rect, name)
@@ -387,6 +413,8 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let f = super::writable(C, Some(format), None)?;
let failed = vectorcraft_render::decode_failures();
let mut enc = encode_files(doc, f, p)?;
// Baking, flattening and drawing stop early when cancelled and leave a truncated result.
vectorcraft_doc::cancel::check()?;
// An image the renderer couldn't decode draws nothing: say so rather than write a picture
// that silently lacks it.
if f.raster && vectorcraft_render::decode_failures() > failed {
@@ -408,6 +436,15 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "ai" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
// `rect`: that area of the page, as if it were the only artboard (the document is not changed).
if let Some(rect) = page_rect(f, p)? {
let mut q = super::export::without_artboards(p);
if let Some(o) = q.as_object_mut() {
o.remove("rect");
o.remove("useArtboards");
}
return encode_files(&single_artboard(doc, rect, "Rect"), f, &q);
}
if use_artboards == Some(false) {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p);
@@ -467,7 +504,10 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
};
let mut enc = Encoded::default();
let mut renderer = vectorcraft_render::Renderer::new();
for b in chosen {
let total = chosen.len() as u64;
for (done, b) in chosen.into_iter().enumerate() {
vectorcraft_doc::cancel::check()?;
vectorcraft_doc::cancel::progress(done as u64, total, &format!("Rendering artboard {} of {total}", done + 1));
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
check_format_size(f, region.width() * scale, region.height() * scale)?;
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
@@ -479,6 +519,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
} else {
bytes
};
// PNG: say the colours are sRGB (what the renderer draws), unless `embedIcc` is false.
let bytes =
if format == RasterFormat::Png && settings.jpeg.embed_icc { vectorcraft_render::encode::png::with_srgb(bytes) } else { bytes };
enc.files.push((Some(b), bytes));
}
if enc.files.is_empty() {
+26 -7
View File
@@ -377,6 +377,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
doc.template = false;
doc.title = s.next_untitled();
}
// A .ai (or .ait) another app wrote carries no open Layers rows we read: all of them start
// closed, its top-level layers as its groups (#1475). Ours carry their own.
if matches!(format.id, "ai" | "ait") && !restored && doc.layers_open.is_none() {
doc.layers_open = Some(vec![]);
}
// Save writes the file back only in a format it writes, only a whole PDF (see
// [`LoadOptions::is_partial`]), and a .ai file only when it carried the native document (Save
// writes .ai that way).
@@ -442,18 +447,28 @@ pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
/// decode, and for bytes that aren't an image.
pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
use image::ImageDecoder as _;
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
return Some(format!("an image of {w} × {h} pixels is too large to draw"));
}
let d = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?;
let (w, h) = d.dimensions();
(d.total_bytes() > MAX_RASTER_ALLOC).then(|| format!("an image of {w} × {h} pixels is too large to draw"))
}
/// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as
/// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which
/// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop
/// documents, whose merged image is read ([`psdread`]). Grayscale TIFFs with an alpha channel or
/// other extra channels, which the decoder can't read either, become RGBA too.
/// they are, with their ink amounts ([`ImageBlob::cmyk`]), except that ones storing one plane per
/// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]);
/// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color
/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]).
/// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read
/// either, become RGBA too, whether they store their channels together or one plane per channel.
/// A TIFF with more strips or tiles than the decoder can count
/// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw.
pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
let ppi = super::ppi::resolution(bytes);
if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) {
return Err(err(format!("an image of {w} × {h} pixels is too large to draw")));
}
if psdread::is_psd(bytes) {
let cms = vectorcraft_color::cms::active();
return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi);
@@ -464,10 +479,14 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
reader.limits(limits);
let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?;
let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?;
let cmyk = || ImageBlob::new(f.mime, bytes.to_vec()).cmyk().is_some();
if matches!(f.id, "png" | "jpg" | "gif" | "webp") || (f.id == "tiff" && cmyk()) {
let kept = match f.id {
"png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()),
"tiff" => vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, ppi),
_ => None,
};
if let Some(kept) = kept {
let (width, height) = reader.into_dimensions().map_err(err)?;
return stored(bytes.to_vec(), f.mime, (width, height), ppi);
return stored(kept, f.mime, (width, height), ppi);
}
let extra_samples = || {
let cms = vectorcraft_color::cms::active();
+29 -12
View File
@@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: 72 (raster formats: pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc,
export::export
),
@@ -186,7 +186,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
[],
None,
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for Override Save/Export). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document (source: datasets; File + Data Set Name for the folder destination and Override Save/Export, with `/`, `\\` and `:` in the data set's name as `-`, and `-2`, `-3`… added to a name already used). Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
always,
action_batch::action_batch
),
@@ -311,6 +311,12 @@ const USE_ARTBOARDS: FormatOption = FormatOption {
default: "null",
description: "true: every chosen artboard (default all), one file each named <file>-<artboard>.<ext> (PDF and AI: one page each); false: the bounds of the visible art",
};
const RECT: FormatOption = FormatOption {
name: "rect",
ty: "array",
default: "null",
description: "[x, y, width, height] in document points, y down: exports that area of the page (no artboard needed, the document is not changed); not with artboard, artboards, range or useArtboards",
};
const PPI: FormatOption = FormatOption {
name: "ppi",
ty: "number",
@@ -348,7 +354,7 @@ const EMBED_ICC: FormatOption = FormatOption {
name: "embedIcc",
ty: "boolean",
default: "true",
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve",
description: "embed the colour profile: sRGB (RGB), the working CMYK space (CMYK) or gray with sRGB's tone curve; PNG: mark the file as sRGB (an sRGB chunk)",
};
const IMAGE_MAP: FormatOption = FormatOption {
name: "imageMap",
@@ -398,6 +404,7 @@ const PALETTE_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -419,7 +426,7 @@ const BYTE_ORDER: FormatOption = FormatOption {
description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)",
};
const TIFF_OPTIONS: &[FormatOption] =
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
&[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC];
const BMP_COLOR_MODEL: FormatOption =
FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" };
const BMP_DEPTH: FormatOption = FormatOption {
@@ -447,6 +454,7 @@ const BMP_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -466,7 +474,7 @@ const TGA_DEPTH: FormatOption = FormatOption {
default: "24",
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
};
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
const PSD_LAYERS: FormatOption = FormatOption {
name: "layers",
ty: "boolean",
@@ -490,6 +498,7 @@ const PSD_OPTIONS: &[FormatOption] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -560,7 +569,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED],
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED, EMBED_ICC],
},
Format {
id: "jpg",
@@ -575,6 +584,7 @@ pub const FORMATS: &[Format] = &[
ARTBOARDS,
RANGE,
USE_ARTBOARDS,
RECT,
PPI,
SCALE,
BACKGROUND,
@@ -596,7 +606,7 @@ pub const FORMATS: &[Format] = &[
read: true,
write: true,
raster: true,
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, RECT, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY],
},
Format {
id: "tiff",
@@ -851,6 +861,8 @@ pub(crate) fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
// Work that stops early for a cancel can leave a truncated result: none of it is written.
vectorcraft_doc::cancel::check()?;
may_write(path)?;
// Never a half-written file: see [`write_atomic`].
write_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
@@ -859,6 +871,7 @@ pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
/// [`write_file`] with what `fill` writes into the file (a save streamed into it).
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> {
vectorcraft_doc::cancel::check()?;
may_write(path)?;
vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
@@ -1063,14 +1076,18 @@ pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) ->
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
/// (compared without case).
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
distinct_names(names.into_iter().map(|(name, fallback)| {
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() { fallback } else { base }
}))
}
/// `names` with `-2`, `-3`… after a name already used (compared without case).
fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
let mut taken = std::collections::HashSet::new();
names
.into_iter()
.map(|(name, fallback)| {
let mut base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
if base.is_empty() {
base = fallback;
}
.map(|base| {
let mut name = base.clone();
for i in 2.. {
if taken.insert(name.to_lowercase()) {
+5
View File
@@ -169,6 +169,7 @@ pub(crate) fn merge(base: &mut Value, over: &Value) {
pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
match e {
PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()),
PdfError::Cancelled => EngineError::Cancelled,
e => EngineError::Other(e.to_string()),
}
}
@@ -273,7 +274,11 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
}
}
let set = &opts.settings;
if set.pdf13() {
vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency");
}
let flat = flatten_for(cmd, doc, set, p)?;
vectorcraft_doc::cancel::check()?;
if flat.is_some() {
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
+18 -2
View File
@@ -185,6 +185,9 @@ fn parent_folder(path: &str) -> Option<String> {
fn save_format_of(f: &str) -> std::result::Result<&'static Format, String> {
match format(f) {
Some(f) if SAVE_FORMATS.contains(&f.id) => Ok(f),
Some(f) if TEMPLATE_FORMATS.contains(&f.id) => {
Err(format!("Save writes {}, not {}: use file.saveAsTemplate to write it", SAVE_FORMATS.join(", "), f.label))
}
Some(f) => Err(format!("Save writes {}, not {}: use document.export for other formats", SAVE_FORMATS.join(", "), f.label)),
None => Err(format!("unknown format `{f}` (Save writes {})", SAVE_FORMATS.join(", "))),
}
@@ -196,6 +199,18 @@ pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Res
save_format_of(format.or_else(|| path.and_then(format_for_name).map(|f| f.id)).unwrap_or("vectorcraft"))
}
/// `path` for a template in format `f`: kept when its extension names `f`, with the extension
/// replaced when it names another format (`card.png` → `card.vctemplate`), else with `f`'s
/// extension added (`card`, `card v1.2`).
fn template_path(path: &str, f: &Format) -> String {
let ext = f.extensions[0];
match format_for_name(path) {
Some(g) if g.id == f.id => path.to_string(),
Some(_) => std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned(),
None => format!("{path}.{ext}"),
}
}
/// [`save_format_of`] for command `cmd`.
fn checked(cmd: &str, f: &str) -> Result<&'static Format> {
save_format_of(f).map_err(|e| bad(cmd, e))
@@ -217,6 +232,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
(_, None, Some(f)) => checked(cmd, f.id)?,
(_, None, None) => checked(cmd, st.format)?,
};
let path = if mode == SaveMode::Template { path.map(|p| template_path(&p, format)) } else { path };
let mut given: Map<String, Value> = match p.get("options") {
Some(Value::Object(o)) => o.iter().filter(|(k, _)| reads_option(format, k)).map(|(k, v)| (k.clone(), v.clone())).collect(),
None | Some(Value::Null) => Map::new(),
@@ -755,7 +771,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save As…",
["File"],
Some("Cmd+Shift+S"),
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
has_doc,
|s, p| save(s, SaveMode::SaveAs, p)
),
@@ -773,7 +789,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save as Template…",
["File"],
None,
"{…as document.save} {path?, format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
"{…as document.save} {path? (saved with the format's extension: one naming another format is replaced, and one is added to a name without it), format?: template (default) | ait (also when format is not given and the path ends in .ait), compress?, version?, preview?} a template that opens as a new untitled document: a native template (.vctemplate), or a PDF-compatible .ait written as a .ai file is (the .ai options and the PDF options, as document.save takes them for .ai); the document is unchanged → {path, format, bytes, warnings}; no path → {dataBase64, format, name: \"<name> template.<ext>\", folder: the Templates folder (preference templatesFolder), warnings}",
has_doc,
|s, p| save(s, SaveMode::Template, p)
),
+3 -3
View File
@@ -148,9 +148,9 @@ fn safe_name(s: &str) -> Option<String> {
(!n.trim_matches('.').is_empty()).then_some(n)
}
/// Text that goes into a file name (prefix, suffix): path separators and `:` become `-`, so it can't
/// start a folder or climb out of the export folder.
fn file_part(s: &str) -> String {
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch): path separators and
/// `:` become `-`, so the name cannot make a subfolder or point outside the folder the file is written to.
pub(super) fn file_part(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
}
+96 -1
View File
@@ -114,6 +114,100 @@ fn cmyk_tiffs_keep_their_inks_and_other_tiffs_become_png() {
assert_eq!(rgb.blob.mime, "image/png");
}
/// A 3 × 2 TIFF at 150 ppi of `channels` (`extra`: their ExtraSamples values), 8 or 16 `bits` a
/// sample, chunky or `planar`.
fn hand_tiff(photometric: u16, bits: u16, extra: &[u16], planar: bool, channels: &[Vec<u16>]) -> Vec<u8> {
let t = vectorcraft_testkit::tiff::Tiff { width: 3, height: 2, photometric, bits, extra, planar, ppi: Some(150), channels };
vectorcraft_testkit::tiff::tiff(&t)
}
/// What Place keeps of TIFF `bytes`: the ink amounts, when it keeps them, and the pixels any image
/// reader decodes from the stored image, as RGBA.
fn kept(what: &str, bytes: &[u8]) -> (Option<Vec<u8>>, Vec<u8>) {
let r = raster_image(bytes).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!((r.width, r.height, r.ppi), (3, 2, Some((150.0, 150.0))), "{what}");
assert_eq!(super::ppi::resolution(&r.blob.bytes), Some((150.0, 150.0)), "{what}: the stored image keeps the resolution");
let rgba = image::load_from_memory(&r.blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_rgba8().into_raw();
(r.blob.cmyk().map(|i| i.data), rgba)
}
/// TIFFs that store one plane per channel (PlanarConfiguration 2) read as their chunky versions do:
/// grayscale and RGB with extra channels, and CMYK with and without them (#1466).
#[test]
fn planar_tiffs_read_as_their_chunky_versions() {
let ch = |v: [u16; 6]| v.to_vec();
let (gray, alpha, spot) = (ch([0, 51, 102, 153, 204, 255]), ch([255, 128, 0, 255, 64, 255]), ch([9, 8, 7, 6, 5, 4]));
let (r, g, b) = (ch([255, 0, 0, 10, 20, 30]), ch([0, 255, 0, 40, 50, 60]), ch([0, 0, 255, 70, 80, 90]));
let (c, m, y, k) = (ch([255, 0, 0, 0, 12, 200]), ch([0, 255, 0, 0, 34, 100]), ch([0, 0, 255, 0, 56, 50]), ch([0, 0, 0, 255, 78, 0]));
// Pixel by pixel, the samples of `px`.
let interleave = |px: &[&Vec<u16>]| -> Vec<u8> { (0..6).flat_map(|i| px.iter().map(move |v| v[i] as u8)).collect() };
let opaque = ch([255; 6]);
let inks = interleave(&[&c, &m, &y, &k]);
// (photometric, ExtraSamples, channels, the RGBA pixels when the color is gray or RGB, the inks when it is CMYK)
type Case = (u16, Vec<u16>, Vec<Vec<u16>>, Option<Vec<u8>>, Option<Vec<u8>>);
let cases: Vec<Case> = vec![
(1, vec![2], vec![gray.clone(), alpha.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
(1, vec![2, 0], vec![gray.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&gray, &gray, &gray, &alpha])), None),
(2, vec![0], vec![r.clone(), g.clone(), b.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &opaque])), None),
(2, vec![2, 0], vec![r.clone(), g.clone(), b.clone(), alpha.clone(), spot.clone()], Some(interleave(&[&r, &g, &b, &alpha])), None),
(5, vec![], vec![c.clone(), m.clone(), y.clone(), k.clone()], None, Some(inks.clone())),
(5, vec![0], vec![c.clone(), m.clone(), y.clone(), k.clone(), spot.clone()], None, Some(inks.clone())),
(5, vec![2], vec![c.clone(), m.clone(), y.clone(), k.clone(), alpha.clone()], None, None),
];
for (photometric, extra, channels, want_rgba, want_inks) in cases {
for bits in [8, 16] {
let wide: Vec<Vec<u16>> = channels.iter().map(|ch| ch.iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect();
let what = format!("photometric {photometric}, extra samples {extra:?}, {bits} bits");
let chunky = kept(&format!("{what}, chunky"), &hand_tiff(photometric, bits, &extra, false, &wide));
let planar = kept(&format!("{what}, planar"), &hand_tiff(photometric, bits, &extra, true, &wide));
assert_eq!(planar.0, chunky.0, "{what}: planar keeps the inks chunky keeps");
assert_eq!(planar.0, want_inks, "{what}: the inks");
// A 16-bit chunky CMYK file is stored as it is, and a planar one is written again with
// its 8-bit inks: their RGB pixels may be a level apart.
let close = planar.1.len() == chunky.1.len() && planar.1.iter().zip(&chunky.1).all(|(a, b)| a.abs_diff(*b) <= 1);
assert!(close, "{what}: planar reads as chunky: {:?} against {:?}", planar.1, chunky.1);
if let Some(want) = &want_rgba {
assert_eq!(&planar.1, want, "{what}: the pixels");
}
}
}
}
/// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows
/// a strip each, times 5 planes) is refused as too large to draw, without a panic.
#[test]
fn planar_tiffs_with_more_strips_than_can_be_counted_are_too_large() {
let plane = |v: u16| vec![v; 6];
let mut file = hand_tiff(5, 8, &[0], true, &[plane(200), plane(40), plane(10), plane(0), plane(90)]);
// ImageLength (the directory's second entry, its value at offset 30) and RowsPerStrip (the
// eighth, at offset 102).
file[30..34].copy_from_slice(&(1u32 << 31 | 1).to_le_bytes());
file[102..106].copy_from_slice(&1u32.to_le_bytes());
let e = raster_image(&file).err().expect("refused");
assert!(e.to_string().contains("an image of 3 × 2147483649 pixels is too large to draw"), "{e}");
assert!(too_large_note(&file).is_some());
assert_eq!(vectorcraft_doc::cmyk::oversized_tiff(&file), Some((3, 1 << 31 | 1)));
}
/// A planar CMYK TIFF opens and places with its ink amounts, stored as a TIFF the image decoder
/// reads (#1466).
#[test]
fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
let inks = [vec![255u16, 0, 12, 0, 40, 9], vec![0, 255, 34, 0, 50, 8], vec![0, 0, 56, 0, 60, 7], vec![0, 0, 78, 255, 70, 6]];
let file = hand_tiff(5, 8, &[], true, &inks);
let want: Vec<u8> = (0..6).flat_map(|i| inks.iter().map(move |v| v[i] as u8)).collect();
assert!(image::load_from_memory(&file).is_err(), "the image decoder reads planar CMYK now");
let mut s = Session::new();
open(&mut s, "planar.tif", &file);
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
assert_eq!(blob.cmyk().map(|i| i.data), Some(want.clone()), "Open keeps the inks");
let mut s = session(100.0, 100.0, 1);
s.execute("file.place", &json!({"name": "planar.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
let blob = &s.doc().unwrap().doc.images[&image_of(&s).key];
assert_eq!(blob.cmyk().map(|i| i.data), Some(want), "Place keeps the inks");
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
}
#[test]
fn content_beats_a_wrong_extension() {
let png = image_bytes(2, 2, image::ImageFormat::Png);
@@ -129,7 +223,8 @@ fn templates_open_untitled() {
let mut s = session(100.0, 100.0, 1);
let dir = tmp_dir("tpl");
let path = dir.join("t.vectorcraft").to_string_lossy().to_string();
s.execute("file.saveAsTemplate", &json!({"path": path})).unwrap();
// A template under a native file name still opens untitled.
std::fs::write(&path, b64(&s.execute("file.saveAsTemplate", &json!({})).unwrap())).unwrap();
let r = s.execute("document.open", &json!({"path": path})).unwrap();
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
assert_eq!(s.doc().unwrap().path, None);
@@ -374,3 +374,43 @@ fn bled_file_opens_on_its_artboard(name: &str, key: &str) {
open(&mut s, name, &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{name}: the editing data's layers are read");
}
/// The rows that open in the Layers panel (layers, sublayers and groups), and which of them are
/// open.
fn open_rows(s: &Session) -> (Vec<String>, Vec<String>) {
let st = s.doc().unwrap();
let (mut rows, mut open) = (vec![], vec![]);
st.doc.walk(|n| {
if n.children().is_some() {
rows.push(n.name.clone().unwrap_or_default());
if st.layers_open.contains(n.id) {
open.push(n.name.clone().unwrap_or_default());
}
}
});
(rows, open)
}
/// #1475: a `.ai` another app wrote keeps no open rows VectorCraft reads, so it opens with every
/// row closed, its top-level layers as its groups; a `.ai` VectorCraft wrote, and other formats,
/// open as before (with their top-level layers open unless the file says otherwise).
#[test]
fn an_illustrator_ai_file_opens_with_its_layers_closed() {
let mut s = Session::new();
for file in [ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), ai::eps(&ai::sample_data(), ai::page_ps())] {
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
let (rows, open) = open_rows(&s);
assert!(rows.contains(&"Art".into()) && rows.contains(&"Pair".into()) && rows.contains(&"Sub".into()), "{rows:?}");
assert_eq!(open, Vec::<String>::new(), "{r}");
}
// The same art as an EPS: its top-level layers open.
open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
// Saved as .ai by VectorCraft, it opens with the rows it was saved with.
let saved = s.execute("document.save", &json!({"format": "ai"})).unwrap();
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
assert_eq!(r["restored"], json!(true), "{r}");
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
}
@@ -201,6 +201,37 @@ fn save_as_template_writes_an_ait_that_new_from_template_opens_untitled() {
assert!(vectorcraft_pdf::editing(&b64(&r)).is_some_and(|e| e.intact));
}
/// Save as Template writes a file named with its template format's extension: another format's
/// extension is replaced and a name without one gets it; Save and Save As name the command that
/// writes a `.ait`.
#[test]
fn save_as_template_names_its_file_after_the_template_format() {
let mut s = rich();
for (name, format, saved) in [
("card.png", None, "card.vctemplate"),
("card.vectorcraft", None, "card.vctemplate"),
("card.ait", Some("template"), "card.vctemplate"),
("card.pdf", Some("ait"), "card.ait"),
("card v1.2", None, "card v1.2.vctemplate"),
] {
let (typed, want) = (tmp(&format!("named/{name}")), tmp(&format!("named/{saved}")));
let _ = std::fs::create_dir_all(std::path::Path::new(&typed).parent().unwrap());
let _ = std::fs::remove_file(&typed);
let _ = std::fs::remove_file(&want);
let r = s.execute("file.saveAsTemplate", &json!({"path": typed, "format": format})).unwrap();
assert_eq!(r["path"], want.as_str(), "{name}: {r}");
let bytes = std::fs::read(&want).unwrap_or_else(|e| panic!("{name}: {saved} not written: {e}"));
let ait = saved.ends_with(".ait");
assert_eq!(bytes.starts_with(b"%PDF"), ait, "{name}");
assert!(ait || vectorcraft_format::load(&bytes).is_ok_and(|d| d.template), "{name}: a native template");
assert!(!std::path::Path::new(&typed).exists(), "{name}: nothing written under the typed name");
}
for (cmd, p) in [("file.saveAs", json!({"format": "ait"})), ("document.save", json!({"path": tmp("card.ait")}))] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("file.saveAsTemplate") && !e.contains("document.export"), "{cmd}: {e}");
}
}
#[test]
fn standards_and_screens_leave_the_editing_data_out() {
let mut s = rich();
@@ -159,6 +159,21 @@ fn black_output_rich_covers_ink_plane_documents_and_leaves_cmyk_exports_alone()
assert!(inks == [cmyk(&mut s, "tiff"), cmyk(&mut s, "jpg")], "CMYK exports keep the document's inks");
}
#[test]
fn png_says_it_is_srgb_unless_embed_icc_is_false() {
let mut s = session(40.0, 30.0, 1);
s.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap();
let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap());
let on = png(&mut s, json!({"format": "png"}));
assert_eq!(png_chunk(&on, b"sRGB"), Some(&[0u8][..]), "an sRGB chunk, perceptual intent");
let off = png(&mut s, json!({"format": "png", "embedIcc": false}));
assert!(png_chunk(&off, b"sRGB").is_none());
assert_eq!(rgba(&on), rgba(&off), "same pixels either way");
let r = s.execute("document.formats", &json!({})).unwrap();
let f = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert!(f["options"].get("embedIcc").is_some(), "png lists embedIcc");
}
#[test]
fn formats_list_the_raster_options() {
let mut s = Session::new();
@@ -217,6 +232,53 @@ fn an_image_opens_at_the_size_its_resolution_declares() {
}
}
#[test]
fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() {
// While another thread keeps exporting a document whose image can't be decoded (two exports at
// once, or the canvas drawing such a document), PNG exports of art with no image report
// nothing, and every export on the other thread still reports its own image.
use std::sync::atomic::{AtomicBool, Ordering};
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
let mut broken = vectorcraft_doc::Document::new(40.0, 20.0);
broken.images.insert("broken".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec()));
let im = ImageObject {
key: "broken".into(),
width: 2,
height: 1,
xf: vectorcraft_geom::Affine::scale(20.0),
link: None,
placement: Default::default(),
};
let (id, layer) = (broken.alloc_id(), broken.layers[0].id);
broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
let mut s = session(400.0, 400.0, 1);
s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 360, "height": 360})).unwrap();
let says_so = |w: &[Value]| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded")));
let stop = AtomicBool::new(false);
let (tx, rx) = std::sync::mpsc::channel();
// Nothing in the scope panics, so the other thread is always told to stop.
let (theirs, ours) = std::thread::scope(|scope| {
let other = scope.spawn(|| {
let mut warned = vec![];
while !stop.load(Ordering::Relaxed) {
let w = encode_with_warnings(&broken, "png", &json!({})).map(|(_, w)| w.into_iter().map(Value::from).collect::<Vec<_>>());
warned.push(w.is_ok_and(|w| says_so(&w)));
let _ = tx.send(());
}
warned
});
let started = rx.recv_timeout(std::time::Duration::from_secs(60)).is_ok();
let ours: Vec<Option<bool>> = (0..5)
.map(|_| s.execute("document.export", &json!({"format": "png"})).ok().map(|r| says_so(r["warnings"].as_array().map_or(&[], |w| w))))
.collect();
stop.store(true, Ordering::Relaxed);
(other.join().ok(), started.then_some(ours))
});
let theirs = theirs.expect("the other thread's exports");
assert!(!theirs.is_empty() && theirs.iter().all(|w| *w), "the other thread's exports: {theirs:?}");
assert_eq!(ours.expect("the other thread started"), vec![Some(false); 5], "exports with no image");
}
#[test]
fn a_raster_export_says_when_an_image_could_not_be_decoded() {
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short:
@@ -233,3 +295,41 @@ fn a_raster_export_says_when_an_image_could_not_be_decoded() {
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "{r}");
}
#[test]
fn export_rect_writes_that_page_area_and_leaves_the_document_alone() {
let mut s = session(200.0, 400.0, 1);
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
let boards = s.doc().unwrap().doc.artboards.len();
let r = s.execute("document.export", &json!({"format": "png", "rect": [20, 40, 40, 300], "ppi": 72})).unwrap();
let img = rgba(&b64(&r));
assert_eq!((img.width(), img.height()), (40, 300));
assert_eq!(img.get_pixel(20, 150).0[3], 255, "the strip is all art");
assert_eq!(s.doc().unwrap().doc.artboards.len(), boards, "no helper artboard is left");
// Twice the resolution, and a window that holds both art and empty page.
let img = rgba(&b64(&s.execute("document.export", &json!({"format": "png", "rect": [0.0, 40.0, 40.0, 10.0], "scale": 2})).unwrap()));
assert_eq!((img.width(), img.height()), (80, 20));
assert_eq!((img.get_pixel(10, 10).0[3], img.get_pixel(60, 10).0[3]), (0, 255));
}
#[test]
fn export_rect_refuses_what_cannot_be_exported() {
let mut s = session(200.0, 400.0, 1);
s.execute("shape.rectangle", &json!({"x": 20, "y": 40, "width": 40, "height": 300})).unwrap();
let bad = [
json!({"format": "png", "rect": [0, 0, 0, 10]}),
json!({"format": "png", "rect": [0, 0, -5, 10]}),
json!({"format": "png", "rect": [0, 0, 10]}),
json!({"format": "png", "rect": [0, 0, "a", 10]}),
json!({"format": "png", "rect": "20,40,40,300"}),
json!({"format": "png", "rect": [1e308, 0, 1e308, 10]}),
json!({"format": "png", "rect": [0, 0, 100000, 100000]}),
json!({"format": "png", "rect": [0, 0, 10, 10], "artboard": 0}),
json!({"format": "png", "rect": [0, 0, 10, 10], "useArtboards": true}),
json!({"format": "svg", "rect": [0, 0, 10, 10]}),
];
for p in bad {
assert!(s.execute("document.export", &p).is_err(), "{p}");
}
}
+5
View File
@@ -818,7 +818,10 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
let cmyk = src.color_mode == ColorMode::Cmyk;
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
// Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing).
vectorcraft_doc::cancel::check()?;
for g in overlapping(&reaches) {
vectorcraft_doc::cancel::check()?;
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
let group = g.iter().any(|&i| see_through[i]).then(|| {
let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect();
@@ -950,6 +953,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
);
let mut r = vectorcraft_render::Renderer::new();
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
// The drawing stops early when cancelled: this image is not whole.
vectorcraft_doc::cancel::check()?;
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
// Without transparency the clip alone shapes the image: it is opaque, over white (its
// edge pixels too).
+69 -11
View File
@@ -82,20 +82,42 @@ impl Cell {
}
}
/// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside).
fn split_unquoted(s: &str, sep: char) -> Vec<&str> {
let mut parts = vec![];
let mut quoted = false;
let mut start = 0;
for (i, ch) in s.char_indices() {
if ch == '"' {
quoted = !quoted;
} else if ch == sep && !quoted {
parts.push(s.get(start..i).unwrap_or_default());
start = i + ch.len_utf8();
}
}
parts.push(s.get(start..).unwrap_or_default());
parts
}
/// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed.
fn cell(c: &str) -> Cell {
let c = c.trim();
match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) {
Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true },
None => Cell { text: c.trim_matches('"').to_string(), quoted: false },
}
}
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
let rows: Vec<Vec<Cell>> = csv
.lines()
// Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes.
let rows: Vec<Vec<Cell>> = split_unquoted(csv, '\n')
.into_iter()
.map(|l| l.trim_end_matches('\r'))
.filter(|l| !l.trim().is_empty())
.map(|l| {
let sep = if l.contains('\t') { '\t' } else { ',' };
l.split(sep)
.map(|c| {
let c = c.trim();
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
Cell { text: c.trim_matches('"').to_string(), quoted }
})
.collect()
let tabs = split_unquoted(l, '\t');
let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') };
parts.into_iter().map(cell).collect()
})
.collect();
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
@@ -192,7 +214,9 @@ fn drop_empty_rows(g: &mut GraphSpec) {
fn fmt_value(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 {
format!("{}", v.round() as i64)
// Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so
// `{:.0}` prints its exact digits.
if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") }
} else {
format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string()
}
@@ -1900,6 +1924,16 @@ mod tests {
use crate::{NodeId, Session};
#[test]
fn fmt_value_does_not_saturate_beyond_i64() {
assert_eq!(super::fmt_value(1e19), "10000000000000000000");
assert_eq!(super::fmt_value(2e19), "20000000000000000000");
assert_eq!(super::fmt_value(-2e19), "-20000000000000000000");
assert_eq!(super::fmt_value(42.0), "42");
assert_eq!(super::fmt_value(-0.0), "0");
assert_eq!(super::fmt_value(2.5), "2.5");
}
fn graph(s: &mut Session, ty: &str) -> NodeId {
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
NodeId(
@@ -2358,6 +2392,30 @@ mod tests {
assert_eq!(cells, g.cells(), "{csv}");
}
#[test]
fn labels_with_commas_quotes_and_newlines_round_trip_through_csv() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let series = json!(["Revenue, USD", "Say \"hi\"", "a\tb"]);
let categories = json!(["Doe, Jane", "2024", " padded ", "two\nlines"]);
let rows = json!([[1, 2, 3], [4, null, 6], [7, 8, 9], [10, 11, 12]]);
let r = s
.execute(
"graph.create",
&json!({"type": "column", "x": 20, "y": 20, "width": 300, "height": 200, "series": series, "categories": categories, "rows": rows}),
)
.unwrap();
let id = r["id"].as_u64().unwrap();
let before = s.execute("graph.setData", &json!({"id": id})).unwrap();
assert_eq!(before["series"], series);
assert_eq!(before["categories"], categories);
s.execute("graph.setData", &json!({"id": id, "csv": before["csv"]})).unwrap();
let after = s.execute("graph.setData", &json!({"id": id})).unwrap();
assert_eq!(after["series"], before["series"], "{}", before["csv"]);
assert_eq!(after["categories"], before["categories"], "{}", before["csv"]);
assert_eq!(after["rows"], before["rows"], "{}", before["csv"]);
}
#[test]
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
+37 -1
View File
@@ -5,7 +5,8 @@
//! `object.intertwine.arrange` picks overlap areas, by a click (`at`) or by an enclosed path drawn
//! around them (`area`), and changes which member is drawn on top there: each member keeps the
//! area where it is drawn over the others (`NodeExtras::intertwine`, see
//! `vectorcraft_doc::intertwine`).
//! `vectorcraft_doc::intertwine`). Object › Expand turns the group into a plain group of what it
//! draws ([`expand_intertwines`]), which Ungroup takes apart.
//!
//! An overlap area is a face of the planar arrangement of what the members paint (their fills and
//! strokes, type outlined; other objects by their bounds): the part where two or more of them
@@ -186,6 +187,41 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- Expand ----------
/// Replace Intertwine group `id` by a plain group of the art it draws
/// ([`vectorcraft_doc::intertwine::expand`]: its objects, then each part drawn over the others as a
/// clip group), keeping everything about the group itself; the generated art gets ids of its own.
fn expand_group(d: &mut Document, id: NodeId) -> Result<()> {
let Some(mut g) = d.node(id).cloned() else { return Ok(()) };
let NodeKind::Intertwine { children } = &g.kind else { return Ok(()) };
g.kind = NodeKind::Group { children: vectorcraft_doc::intertwine::expand(children).into_iter().map(Arc::new).collect(), clip: false };
super::live::fix_ids(d, &mut g);
*d.node_mut(id).ok_or(EngineError::NoNode(id))? = g;
Ok(())
}
/// Object › Expand: every Intertwine group in the subtrees of `roots` becomes a group of the art
/// it draws ([`expand_group`]), inner ones first so an outer one copies plain art. Returns how
/// many. A live Intertwine is expanded before it can be ungrouped:
/// <https://community.adobe.com/questions-652/expand-ungroup-800281>.
pub(crate) fn expand_intertwines(d: &mut Document, roots: &[NodeId]) -> Result<usize> {
let mut groups = vec![];
for r in roots {
if let Some(n) = d.node(*r) {
n.walk(&mut |c| {
if is_intertwine(c) {
groups.push(c.id);
}
});
}
}
for g in groups.iter().rev() {
expand_group(d, *g)?;
}
Ok(groups.len())
}
// ---------- Arrange ----------
#[derive(Clone, Copy, PartialEq)]
+2 -2
View File
@@ -834,7 +834,7 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let i = super::panelcmds::artboard_index(&s.doc()?.doc, p, "artboard.setProps")?;
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let now = rect_from(p, was);
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
@@ -933,8 +933,8 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
}
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let st = s.doc()?;
let i = if p.get("index").is_none() { 0 } else { super::panelcmds::artboard_index(&st.doc, p, "artboard.fitToSelection")? };
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
+13 -2
View File
@@ -271,8 +271,19 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
if ids.len() < 2 {
return Err(bad(C, "highlight two or more rows to merge"));
}
let into =
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
// The last highlighted layer that is not inside another highlighted row: with a layer and its
// sublayer both highlighted (Select All in the panel), the sublayer travels with its layer
// instead of becoming the target (#2383).
let roots: HashSet<NodeId> = row_roots(d, &ids).into_iter().collect();
let is_layer = |id: &NodeId| d.node(*id).is_some_and(Node::is_layer);
let into = ids
.iter()
.rev()
.copied()
.filter(is_layer)
.find(|id| roots.contains(id))
.or_else(|| ids.iter().rev().copied().find(is_layer))
.ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
let around = d.ancestry(into).unwrap_or_default();
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
+1 -1
View File
@@ -383,7 +383,7 @@ fn replace_with(d: &mut Document, id: NodeId, nodes: Vec<Node>) -> Result<Vec<No
}
/// Fresh ids for any id-0 (generated) node in `n`'s subtree; keeps real ids.
fn fix_ids(d: &mut Document, n: &mut Node) {
pub(super) fn fix_ids(d: &mut Document, n: &mut Node) {
if n.id == NodeId(0) {
n.id = d.alloc_id();
}
+12 -3
View File
@@ -664,9 +664,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result<Value> {
return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false }));
}
// Print rasters run to gigabytes decoded: the same allowance opening them has.
let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
.with_guessed_format()
.map_err(|_| bad(C, "the image's pixels can't be read"))?;
let reader = || {
image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice()))
.with_guessed_format()
.map_err(|_| bad(C, "the image's pixels can't be read"))
};
// The image's size as RGBA, read from its header, is held to that allowance before anything
// is decoded (#1717).
let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?;
if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 {
return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw")));
}
let mut reader = reader()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC);
reader.limits(limits);
+7
View File
@@ -103,6 +103,11 @@ impl PreviewMode {
}
}
/// A saved preset's raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
fn de_ppi<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
Ok(Some(f64::deserialize(d)?).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0))
}
/// Everything New Document sets: a preset (built-in, saved or recent) or `file.new`'s params.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -125,6 +130,8 @@ pub struct DocSettings {
pub bleed: [f64; 4],
pub background: Background,
pub color_mode: ColorMode,
/// 1 to 2400 ppi (a saved preset's value outside that range is read as the nearer end).
#[serde(deserialize_with = "de_ppi")]
pub raster_effects_ppi: f64,
pub preview_mode: PreviewMode,
}
+1 -1
View File
@@ -459,7 +459,7 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo
if let Some(k) = &it.copies
&& Arc::ptr_eq(&k.from, &it.doc)
&& k.roots == roots
&& (!k.selection.guides.is_empty() == has_guide_copies)
&& (k.selection.guides.is_empty() != has_guide_copies)
{
return Ok(Some(k.clone()));
}
+82 -29
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Artboard",
["Window", "Artboards"],
None,
"{index?: n (default: the Artboard tool's artboard), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} copy placed right of the last artboard, with copies of the art fully inside it when `art` (locked and hidden art only with prefs moveLockedWithArtboard) → {index, ids: the art's copies}",
"{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}",
has_doc,
artboard_duplicate
),
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Copy Artboard",
[],
None,
"{index?, art?} (defaults as artboard.duplicate) put the artboard, and the art fully inside it when `art`, on the clipboard: edit.paste adds a copy right of the last artboard, edit.pasteInPlace/InFront/InBack where it was, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
"{index? | indices?, art?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}",
has_doc,
|s, p| artboard_copy(s, p, false)
),
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Cut Artboard",
[],
None,
"{index?, art?} artboard.copy, then delete the artboard and that art in one undo step (not the only artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
"{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}",
has_doc,
|s, p| artboard_copy(s, p, true)
),
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "artboard.reorder";
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize;
let i = artboard_index(&s.doc()?.doc, p, C)?;
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize;
s.edit("Reorder Artboards", |d, _| {
if i >= d.artboards.len() {
@@ -68,16 +68,37 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// The artboard (`index`) and whether its art comes along (`art`) for the artboard copy
/// commands: by default the Artboard tool's artboard and its Move/Copy Artwork with Artboard option.
fn board_and_art(s: &Session, p: &Value) -> (usize, bool) {
/// The artboards (`index` or `indices`) and whether their art comes along (`art`) for the artboard
/// 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.
fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<usize>, bool)> {
let t = s.tool_options_of("artboard");
let i = p.get("index").and_then(Value::as_u64).or_else(|| t["active"].as_u64()).unwrap_or(0) as usize;
(i, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))
let d = &s.doc()?.doc;
let indices = if p.get("index").is_some() || p.get("indices").is_some() {
artboard_indices(d, p, cmd)?
} else {
let mut picked: Vec<u64> = t["selected"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
.filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len()))
.collect();
if picked.is_empty() {
picked.push(t["active"].as_u64().unwrap_or(0));
}
artboard_indices(d, &json!({ "indices": picked }), cmd)?
};
Ok((indices, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true))))
}
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
/// [`artboard_indices`] (`missing index`, `no such artboard`).
pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<usize> {
let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?;
artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into()))
}
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
/// ones only with prefs moveLockedWithArtboard), in paint order.
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
/// in order and once each.
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
@@ -93,55 +114,87 @@ pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &s
Ok(indices)
}
/// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden
/// ones only with prefs moveLockedWithArtboard), in paint order.
pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
Ok(if art { d.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] })
}
/// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object
/// counted once, with the first of them it is on (artboards can overlap).
pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool) -> Result<Vec<(usize, Vec<NodeId>)>> {
let mut seen = std::collections::HashSet::new();
let mut arts = vec![];
for &i in indices {
let art = artboard_art(s, i, art)?.into_iter().filter(|id| seen.insert(*id)).collect();
arts.push((i, art));
}
Ok(arts)
}
/// How far a copy of `rect` moves to sit right of the last artboard (Duplicate Artboards, Paste).
pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec2 {
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
Vec2::new(right + 20.0 - rect.x0, 0.0)
}
/// 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.
fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (i, art) = board_and_art(s, p);
let art = artboard_art(s, i, art)?;
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &art)?, ..Scaling::default() };
let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?;
let arts = artboards_art(s, &indices, art)?;
let all: Vec<NodeId> = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect();
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() };
let (index, ids) = s.edit("Duplicate Artboard", |d, _| {
let dv = d.artboards.get(i).map(|a| beside_artboards(d, a.rect)).unwrap_or_default();
copy_artboard(d, i, dv, &art, sc)
let dv = arts
.iter()
.filter_map(|(i, _)| d.artboards.get(*i))
.map(|a| a.rect)
.reduce(|a, b| a.union(b))
.map(|r| beside_artboards(d, r))
.unwrap_or_default();
let (mut index, mut ids) = (vec![], vec![]);
for (i, art) in &arts {
let (at, copies) = copy_artboard(d, *i, dv, art, sc)?;
index.push(at);
ids.extend(copies);
}
Ok((index, ids))
})?;
Ok(json!({"index": index, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
Ok(json!({"index": index.first(), "indices": index, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// Artboard Copy / Cut: the artboard and its art onto the clipboard (pasting brings both); a cut
/// then deletes them.
/// Artboard Copy / Cut: the artboards and their art onto the clipboard (pasting brings them all);
/// a cut then deletes them.
pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Value> {
let (i, art) = board_and_art(s, p);
let art = artboard_art(s, i, art)?;
let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?;
let arts = artboards_art(s, &indices, art)?;
let st = s.doc()?;
if cut && st.doc.artboards.len() <= 1 {
if cut && st.doc.artboards.len() <= indices.len() {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
let board = st.doc.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let clip = Clipboard { source_artboard: Some(board.rect), artboard: Some(board), ..Clipboard::copy(st, &art) };
let art = st.doc.paint_order(arts.into_iter().flat_map(|(_, a)| a));
let boards: Vec<_> = indices.iter().filter_map(|i| st.doc.artboards.get(*i).cloned()).collect();
let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art) };
let copied = clip.nodes.len();
if cut {
s.edit("Cut Artboard", |d, sel| {
if i >= d.artboards.len() {
if indices.iter().any(|i| *i >= d.artboards.len()) {
return Err(EngineError::Other("no such artboard".into()));
}
// Its guides go with it.
let gone = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
// The last first, so the others keep their places; their guides go with them.
for &i in indices.iter().rev() {
let gone = d.artboards.remove(i).id;
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
}
d.remove_many(&art)?;
sel.prune(d);
Ok(())
})?;
if s.tool_id() == "artboard" {
s.set_tool_option("active", &json!(i.saturating_sub(1)));
s.set_tool_option("active", &json!(indices.first().map_or(0, |i| i.saturating_sub(1))));
}
}
s.clipboard = clip;
+40 -3
View File
@@ -13,7 +13,9 @@
//! command with its default. So are the `params` of an effect (#1592): `effect.apply` and
//! `effect.setParams` answer a key the effect doesn't take, or a value of another type than its
//! default, with a parameter error ([`check_effect_params`]). [`Session::execute`] itself takes
//! what it is given, so the journal of an older version still replays.
//! what it is given, so the journal of an older version still replays, except for an effect's
//! `color` that the effect can't read, which `effect.apply` and `effect.setParams` refuse
//! themselves ([`check_effect_color`]).
use serde_json::Value;
@@ -212,8 +214,9 @@ fn unknown(id: &str, key: &str, keys: &[&str]) -> crate::EngineError {
/// `params` of built-in effect `effect`, as command `id` was given them (#1592): only keys the
/// effect takes (those of its dialog defaults and of its params description), each of its
/// default's type. Numbers out of range pass: the effect clamps them when it draws. An effect
/// plug-in resolves its own against its manifest, and an unknown effect is the command's to report.
/// default's type, and a `color` the effect can draw ([`check_effect_color`]). Numbers out of
/// range pass: the effect clamps them when it draws. An effect plug-in resolves its own against
/// its manifest, and an unknown effect is the command's to report.
pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()> {
let Value::Object(given) = params else { return Ok(()) };
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
@@ -240,6 +243,40 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
_ => {}
}
}
check_effect_color(id, effect, params)
}
/// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`,
/// `#rrggbbaa`, black, white, red, green, blue), `[r, g, b]` whose first three entries are numbers
/// (read on a 0..1 scale) or a color object (`{"model": "rgb", "r": …}`)? The effect draws its
/// default color for any other value, except that an array of three or more entries draws an
/// entry that isn't a number as 0.
///
/// [`Color::from_hex`]: vectorcraft_color::Color::from_hex
fn is_effect_color(v: &Value) -> bool {
match v {
Value::String(s) => vectorcraft_color::Color::from_hex(s).is_some(),
Value::Array(a) => a.len() >= 3 && a.iter().take(3).all(Value::is_number),
Value::Object(_) => serde_json::from_value::<vectorcraft_color::Color>(v.clone()).is_ok(),
_ => false,
}
}
/// The `color` in `params` of built-in effect `effect`, when the effect has a `color` parameter,
/// as command `id` was given it: a parameter error naming it unless the effect can read it
/// ([`is_effect_color`]). `effect.apply` and `effect.setParams` run this themselves before they
/// edit, so the app's own edits, the steps of a `command.batch` and a replayed journal are held to
/// it too.
pub(crate) fn check_effect_color(id: &str, effect: &str, params: &Value) -> Result<()> {
let key = "color";
let Some(v) = params.get(key) else { return Ok(()) };
if effect.starts_with(vectorcraft_plugins::effect::PREFIX) {
return Ok(());
}
let takes_color = vectorcraft_render::effects::effect_info(effect).is_some_and(|info| info.defaults.get(key).is_some_and(Value::is_string));
if takes_color && !is_effect_color(v) {
return Err(bad(id, format!("`{key}` must be a #rrggbb color")));
}
Ok(())
}
+17 -5
View File
@@ -18,10 +18,16 @@ pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
/// What `pdf.preset.export` writes and `pdf.preset.import` reads.
#[derive(Serialize, Deserialize)]
struct PresetFile {
format: String,
pub(crate) struct PresetFile {
pub(crate) format: String,
#[serde(default)]
presets: Vec<PdfPreset>,
pub(crate) presets: Vec<PdfPreset>,
}
impl PresetFile {
pub(crate) fn new(presets: Vec<PdfPreset>) -> Self {
Self { format: PRESET_FORMAT.into(), presets }
}
}
pub fn specs() -> Vec<CommandSpec> {
@@ -112,6 +118,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
}
let at = s.pdf_saved_index(&name);
let saved = at.and_then(|i| s.prefs.pdf_presets.get(i)).cloned();
let old_name = saved.as_ref().map(|old| old.name.clone());
// A saved preset changes from its own settings unless `preset` says where to start.
let mut q = if p.is_object() { p.clone() } else { json!({}) };
if let (Some(old), None) = (&saved, str_param(p, "preset")) {
@@ -127,6 +134,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
};
let description = str_param(p, "description").map(str::to_string).or_else(|| saved.map(|old| old.description)).unwrap_or_default();
let preset = PdfPreset { name: name.clone(), description, settings: settings.clone() };
s.store_pdf_preset(old_name.as_deref(), &preset);
match at.and_then(|i| s.prefs.pdf_presets.get_mut(i)) {
Some(slot) => *slot = preset,
None => s.prefs.pdf_presets.push(preset),
@@ -141,7 +149,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
}
let i = s.pdf_saved_index(name).ok_or_else(|| bad(C, format!("no saved PDF preset named `{name}`")))?;
Ok(json!({ "deleted": s.prefs.pdf_presets.remove(i).name }))
let deleted = s.prefs.pdf_presets.remove(i).name;
s.unstore_pdf_preset(&deleted);
Ok(json!({ "deleted": deleted }))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
@@ -196,12 +206,14 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(slot) = s.prefs.pdf_presets.get_mut(i) {
preset.name = slot.name.clone();
imported.push(preset.name.clone());
*slot = preset;
*slot = preset.clone();
s.store_pdf_preset(Some(&preset.name), &preset);
}
}
None => {
preset.name = unique_name(&base, |n| s.pdf_preset_taken(n, None));
imported.push(preset.name.clone());
s.store_pdf_preset(None, &preset);
s.prefs.pdf_presets.push(preset);
}
}
+25 -7
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Setup",
[],
None,
"{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
has_doc,
setup
),
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print Preview",
[],
None,
"{settings?: {…settings of print.setup} (over the document's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing",
"{preset?: name (as print.setup's: its settings instead of the document's), settings?: {…settings of print.setup} (over the document's or the preset's)} → {pages (copies included), sheets: [{artboard (0-based; null with artboards ignored), tile? (1-based), ink? (separations), width, height (pt, the page), orientation, scale: [width %, height %], transform: [a, b, c, d, e, f] (document space → the page, pt, y down), area: [x0, y0, x1, y1] (the document region the page prints), trim: [x0, y0, x1, y1] (the artboard on the page)}] (one copy, in order), tiles: [{artboard, columns, rows, printed: [1-based tiles], tiles: [[x0, y0, x1, y1]…] (document space)}], inks: [{name, spot, print, frequency, angle}] (separations), warnings (art larger than the imageable area, type that prints in the fallback font, type that doesn't fit its frame, options not applied yet…), settings} without printing",
has_doc,
preview
),
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Print",
[],
Some("Cmd+P"),
"{settings?: {…settings of print.setup} (over the document's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
"{preset?: name (as print.setup's: its settings instead of the document's; the saved settings stay), settings?: {…settings of print.setup} (over the document's or the preset's), path?, format?: pdf (default: a print-ready PDF) | postscript (the default for a .ps path: a PostScript file with DSC comments, %%Pages, a page per sheet with its paper size, marks, separations with their ink and halftone screen, the fixed flatness, negatives), level?: 3|2 (the PostScript language level), flattenerPreset?: \"medium\" (PostScript: the preset transparency is flattened with — high, low or a saved one, see flattener.presets.list), acknowledgeLoss?: bool (with path: write over the file the document was read from although reading it left things out)} print the document, one page per sheet of paper (per tile, per ink, per copy) → {pages, format, path, bytes, warnings}; no path → {dataBase64, bytes, pages, format, warnings}. Raster effects print at the document's raster effects resolution; a PDF leaves halftone screens and flatness to the output device",
has_doc,
print
),
@@ -57,6 +57,23 @@ pub(crate) fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSett
settings_over(cmd, saved(doc), p)
}
/// The settings `p` asks for: those of the print preset `p.preset` names ([Default] or a saved
/// one, any case), else those saved with `doc`, with `p.settings` over them, checked.
fn preset_or_saved(cmd: &str, s: &Session, doc: &Document, p: &Value) -> Result<PrintSettings> {
let base = match p.get("preset") {
None | Some(Value::Null) => saved(doc),
Some(Value::String(name)) => {
let set = s.print_preset_settings(name).ok_or_else(|| {
let names: Vec<String> = s.print_presets().into_iter().map(|q| q.name).collect();
bad(cmd, format!("no print preset named `{name}` (presets: {})", names.join(", ")))
})?;
to_json(&set)?
}
Some(_) => return Err(bad(cmd, "preset must be the name of a print preset")),
};
settings_over(cmd, base, p)
}
/// The settings `v` (JSON) with `p.settings` over them, checked.
pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result<PrintSettings> {
match p.get("settings") {
@@ -75,8 +92,9 @@ pub(crate) fn to_json(set: &PrintSettings) -> Result<Value> {
fn setup(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let set = to_json(&settings(SETUP, doc, p)?)?;
if p.get("settings").is_some_and(|v| !v.is_null()) && doc.print_setup.as_ref() != Some(&set) {
let set = to_json(&preset_or_saved(SETUP, s, doc, p)?)?;
let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null());
if (given("settings") || given("preset")) && doc.print_setup.as_ref() != Some(&set) {
let stored = set.clone();
s.edit("Print Setup", |d, _| {
d.print_setup = Some(stored);
@@ -89,7 +107,7 @@ fn setup(s: &mut Session, p: &Value) -> Result<Value> {
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "print.preview";
let doc = &s.doc()?.doc;
let set = settings(C, doc, p)?;
let set = preset_or_saved(C, s, doc, p)?;
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv)));
@@ -148,7 +166,7 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
}
let postscript = to_postscript(p)?;
let doc = &s.doc()?.doc;
let mut set = settings(PRINT, doc, p)?;
let mut set = preset_or_saved(PRINT, s, doc, p)?;
if postscript {
// PostScript has no transparency: `flattenerPreset`, else the Advanced preset, else medium.
let given = str_param(p, "flattenerPreset").filter(|n| !n.trim().is_empty());
+17 -1
View File
@@ -170,6 +170,8 @@ pub(crate) fn effects_scale(doc: &Document) -> f64 {
/// fills' and strokes' raster effects included). With `knockout`, the coverage of that art is knocked out
/// of the image, which then only holds what the effects add around it (a shadow to go under the
/// vector object). The document's raster effects settings add room around it and finish its pixels.
/// `None` when it paints nothing, when the area it covers overflows `f64` or the scale is not
/// positive, or when its PNG can't be encoded.
pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&Node>, scale: f64) -> Option<Node> {
// What it paints (its members' effects included), and how far its fills' and strokes' raster
// effects reach beyond that.
@@ -177,8 +179,16 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
let look = out.raster_effects.clone();
let reach = whole.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(0.0, f64::max) + 2.0 + look.add_around;
let b = b.inflate(reach, reach);
// An area too large for f64 gives a zero scale and an image transform that is not finite.
let area = b.width() * b.height();
if !area.is_finite() {
return None;
}
// Whole pixels, and no larger than 64 Mpx.
let scale = scale.min((64.0e6 / (b.width() * b.height()).max(1.0)).sqrt());
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt());
if !(scale.is_finite() && scale > 0.0) {
return None;
}
let region = Rect::new(b.x0, b.y0, b.x0 + (b.width() * scale).ceil().max(1.0) / scale, b.y0 + (b.height() * scale).ceil().max(1.0) / scale);
// Anti-alias off: hard edges (the effects themselves stay smooth).
let anti_alias = if look.anti_alias { AntiAlias::Art } else { AntiAlias::None };
@@ -430,6 +440,10 @@ fn walk_mask(out: &mut Document, n: &mut Node, report: &mut FlattenReport) {
/// has none. An object whose effects all paint below it keeps its vector art over an image of
/// them; one whose effects change it (blur, feather, inner glow) becomes the image.
fn walk(out: &mut Document, n: &Node, report: &mut FlattenReport) -> Option<Node> {
// A cancelled export leaves the rest of the document as it is: the caller drops the result.
if vectorcraft_doc::cancel::is_cancelled() {
return None;
}
let checkpoint = report.checkpoint();
let result = walk_inner(out, n, report);
report.attribute_since(checkpoint, n);
@@ -548,6 +562,8 @@ pub fn export_pdf_with_report(
) -> std::result::Result<vectorcraft_pdf::ExportReport, vectorcraft_pdf::PdfError> {
let mut report = FlattenReport::new(Which::All, doc);
let flat = flatten(doc, &mut report);
// Flattening stops early when cancelled, leaving a half-flattened copy that must not be written.
vectorcraft_doc::cancel::check().map_err(|_| vectorcraft_pdf::PdfError::Cancelled)?;
let mut exported = vectorcraft_pdf::export_with_report(flat.as_ref().unwrap_or(doc), opts)?;
exported.warnings.extend(report.warnings());
Ok(exported)
+3 -2
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, overflow?: [id] (the text doesn't fit; left out when it fits)}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}",
has_doc,
create_in_path
),
@@ -609,7 +609,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
sel.set([id]);
Ok(id)
})?;
Ok(super::typecmd::with_overflow(s, &[id], json!({"id": id.0})))
let made = super::typecmd::with_bounds(s, Some(id), json!({"id": id.0}));
Ok(super::typecmd::with_overflow(s, &[id], made))
}
/// What `type.step` changes.
+134 -15
View File
@@ -133,11 +133,52 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
m
}
/// Captured character styles set Auto leading explicitly. A missing style attribute instead
/// leaves the text's existing leading alone, unlike a run's omitted Auto value.
fn char_attrs(v: &CharStyle) -> Map<String, Value> {
/// Every character attribute of `v` as a JSON map, without the style name: those the run's JSON
/// leaves out at their defaults (Auto leading and kerning, no OpenType features, Roman baseline
/// alignment…) are written out. A style captured from text sets them all; an attribute missing
/// from a style instead leaves the text's own value alone.
pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
let mut m = to_attrs(v);
m.insert("leading".into(), json!(v.leading));
for (k, x) in [
("font_version", json!(v.font_version)),
("leading", json!(v.leading)),
("kerning", json!(v.kerning)),
("features", json!(v.features)),
("overprint_fill", json!(v.overprint_fill)),
("overprint_stroke", json!(v.overprint_stroke)),
("stroke_cap", json!(v.stroke_cap)),
("stroke_join", json!(v.stroke_join)),
("stroke_miter_limit", json!(v.stroke_miter_limit)),
("stroke_dash", json!(v.stroke_dash)),
("stroke_align", json!(v.stroke_align)),
("position", json!(v.position)),
("small_caps", json!(v.small_caps)),
("charAlign", json!(v.char_align)),
("proportionalMetrics", json!(v.proportional_metrics)),
("digits", json!(v.digits)),
] {
m.insert(k.into(), x);
}
m
}
/// Every paragraph attribute of `v` as a JSON map, without the style name: those the paragraph's
/// JSON leaves out at their defaults (the Hyphenation options, the Every-line Composer, no tab
/// stops, Auto direction…) are written out, as [`char_attrs`] does for characters.
pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
let mut m = to_attrs(v);
for (k, x) in [
("hyphenation", json!(v.hyphenation)),
("composer", json!(v.composer)),
("tabs", json!(v.tabs)),
("mojikumi", json!(v.mojikumi)),
("direction", json!(v.direction)),
("leading_model", json!(v.leading_model)),
("burasagari", json!(v.burasagari)),
("kinsoku", json!(v.kinsoku)),
] {
m.insert(k.into(), x);
}
m
}
@@ -187,13 +228,19 @@ fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Val
}
/// `cur` after its style changed from `old` to `new`: attributes still equal to the old style's
/// value (or not set by it) take the new value; overrides stay. Unchanged if the style doesn't
/// round-trip through JSON.
fn restyle<T: Serialize + DeserializeOwned + Clone>(cur: &T, old: &Map<String, Value>, new: &Map<String, Value>) -> T {
let Some(mut m) = to_map(cur) else { return cur.clone() };
// Runs omit Auto leading from JSON; it still matches a style's explicit null.
if old.get("leading") == Some(&Value::Null) {
m.entry("leading").or_insert(Value::Null);
/// value (or not set by it) take the new value; overrides stay. `attrs` lists every attribute of
/// `cur` ([`char_attrs`], [`para_attrs`]), so one at its default matches a style that sets the
/// default. Unchanged if the style doesn't round-trip through JSON.
fn restyle<T: Serialize + DeserializeOwned + Clone>(
cur: &T,
attrs: fn(&T) -> Map<String, Value>,
old: &Map<String, Value>,
new: &Map<String, Value>,
) -> T {
let Some(full) = to_map(cur) else { return cur.clone() };
let mut m = attrs(cur);
if let Some(name) = full.get("style_name") {
m.insert("style_name".into(), name.clone());
}
for (k, v) in new {
if old.get(k).is_none_or(|o| m.get(k) == Some(o)) {
@@ -246,7 +293,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
Kind::Char => {
for r in &mut t.runs {
if r.style.style_name == target {
r.style = restyle(&r.style, old, new);
r.style = restyle(&r.style, char_attrs, old, new);
changed = true;
}
}
@@ -254,7 +301,7 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Valu
Kind::Para => {
for pa in t.para_styles_mut() {
if pa.style_name == target {
*pa = restyle(pa, old, new);
*pa = restyle(pa, para_attrs, old, new);
changed = true;
}
}
@@ -286,7 +333,7 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
char_attrs(&style)
}
// The paragraph at `start`.
Kind::Para => to_attrs(t.para_at(t.paragraphs_in(at, at).start)),
Kind::Para => para_attrs(t.para_at(t.paragraphs_in(at, at).start)),
}))
}
@@ -314,7 +361,7 @@ fn attrs_param(p: &Value, kind: Kind, verb: &str) -> Result<Option<Map<String, V
let c = kind.cmd(verb);
let full = match kind {
Kind::Char => char_attrs(&with_attrs(&CharStyle::default(), m, &c)?),
Kind::Para => to_attrs(&with_attrs(&ParaStyle::default(), m, &c)?),
Kind::Para => para_attrs(&with_attrs(&ParaStyle::default(), m, &c)?),
};
Ok(Some(m.keys().filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone()))).collect()))
}
@@ -708,6 +755,78 @@ mod tests {
}
}
/// The attribute names a style of type `T` can set, without the style name.
fn settable<T: DeserializeOwned>() -> std::collections::BTreeSet<String> {
attr_names::<T>().into_iter().filter(|&n| n != "style_name").map(String::from).collect()
}
/// New and Redefine capture every paragraph attribute. A paragraph style redefined from a
/// paragraph at the defaults gives its paragraphs the defaults back where they had the old
/// definition's values, and `setAttrs` with a default value does too; local overrides, and
/// attributes `setAttrs` leaves out, stay.
#[test]
fn para_style_redefined_to_the_defaults_updates_its_paragraphs() {
use vectorcraft_doc::{Composer, HyphenationOptions};
let (mut s, source) = session_with_text("Source");
let [linked, local] =
["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap());
let ids = [source, linked, local];
let para = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.para_at(0).clone(),
_ => panic!("not text"),
};
let limits = |s: &Session| ids.map(|id| para(s, id).hyphenation.hyphen_limit);
let composers = |s: &Session| ids.map(|id| para(s, id).composer);
let defined = |s: &Session, i: usize| s.doc().unwrap().doc.para_styles[i].attrs.keys().cloned().collect::<std::collections::BTreeSet<_>>();
s.execute("text.setFormat", &json!({"ids": ids, "hyphenLimit": 2, "composer": "singleLine"})).unwrap();
s.execute("paraStyle.new", &json!({"name": "Body", "id": source})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap();
s.execute("text.setFormat", &json!({"ids": [local], "hyphenLimit": 4})).unwrap();
s.execute("text.setFormat", &json!({"ids": [source], "hyphenLimit": 0, "composer": "everyLine"})).unwrap();
s.execute("paraStyle.redefine", &json!({"name": "Body", "id": source})).unwrap();
assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3]));
assert_eq!(para(&s, linked).hyphenation, HyphenationOptions::default());
assert!(ids.iter().all(|&id| para(&s, id).style_name.as_deref() == Some("Body")));
assert_eq!(defined(&s, 0), settable::<ParaStyle>(), "Redefine captures every attribute");
s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
assert_eq!(defined(&s, 1), settable::<ParaStyle>(), "New captures every attribute");
// Style options: a default value reaches the users too; an attribute left out stays as it is.
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"hyphenation": {"hyphen_limit": 3}, "composer": "singleLine"}})).unwrap();
assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::SingleLine; 3]));
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"composer": "everyLine"}})).unwrap();
assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::EveryLine; 3]));
assert_eq!(Value::Object(s.doc().unwrap().doc.para_styles[0].attrs.clone()), json!({"composer": "everyLine"}));
}
/// The same for character styles: Auto kerning and the Roman baseline alignment, which the
/// run's JSON leaves out, are captured and reach the style's users.
#[test]
fn char_style_redefined_to_the_defaults_updates_its_text() {
use vectorcraft_doc::CharAlign;
let (mut s, source) = session_with_text("Source");
let [linked, local] =
["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap());
let ids = [source, linked, local];
let kerning = |s: &Session| ids.map(|id| runs(s, id)[0].style.kerning);
let aligns = |s: &Session| ids.map(|id| runs(s, id)[0].style.char_align);
let defined = |s: &Session, i: usize| s.doc().unwrap().doc.char_styles[i].attrs.keys().cloned().collect::<std::collections::BTreeSet<_>>();
s.execute("text.setFormat", &json!({"ids": ids, "kerning": 40, "charAlign": "emBoxCenter"})).unwrap();
s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap();
s.execute("text.setFormat", &json!({"ids": [local], "kerning": 60})).unwrap();
s.execute("text.setFormat", &json!({"ids": [source], "kerning": "auto", "charAlign": "romanBaseline"})).unwrap();
s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap();
assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3]));
assert_eq!(defined(&s, 0), settable::<CharStyle>(), "Redefine captures every attribute");
s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
assert_eq!(defined(&s, 1), settable::<CharStyle>(), "New captures every attribute");
s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap();
assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3]));
s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap();
assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::RomanBaseline; 3]));
assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"}));
}
#[test]
fn auto_leading_new_distinguishes_unset_attributes() {
let (mut s, id) = session_with_text("Title");
+35
View File
@@ -269,6 +269,17 @@ pub(crate) fn with_overflow(s: &Session, ids: &[NodeId], mut result: Value) -> V
result
}
/// Adds `bounds` to the result of a command that made the type object `id`: the same
/// `{x, y, width, height}` box `document.inspect` reports for it (after `justify`, so a script can
/// place the new type without a helper object); `null` when it has none.
pub(crate) fn with_bounds(s: &Session, id: Option<NodeId>, mut result: Value) -> Value {
let Ok(st) = s.doc() else { return result };
let symbols = st.doc.symbol_boxes();
let b = id.and_then(|i| st.doc.node(i)).and_then(|n| n.geometric_bounds_with(&symbols));
result["bounds"] = crate::inspect::rect_json(b);
result
}
/// The warning that Print gives for `ids`, area type and type on a path whose text doesn't fit
/// (as [`with_overflow`] finds it): the text past the end of their frames doesn't print. It names
/// the first 20 ids and counts the rest; `None` when `ids` is empty.
@@ -783,6 +794,30 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
mod area_tests {
use super::*;
/// #2413: `text.create` and `text.createInPath` return the new type's `bounds`, the box
/// `document.inspect` reports for it, so a script can place it without a helper object.
#[test]
fn text_create_returns_the_inspect_bounds() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
let inspected = |s: &mut Session, id: &Value| {
let doc = s.execute("document.inspect", &json!({})).unwrap();
fn find(v: &Value, id: &Value) -> Option<Value> {
if &v["id"] == id {
return Some(v["bounds"].clone());
}
v.get("children").or_else(|| v.get("layers"))?.as_array()?.iter().find_map(|c| find(c, id))
}
find(&doc, id).unwrap()
};
let made = s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Hello", "size": 24, "justify": "center"})).unwrap();
assert!(made["bounds"]["width"].as_f64().unwrap() > 10.0, "{made}");
assert_eq!(made["bounds"], inspected(&mut s, &made["id"]));
let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 200, "width": 100, "height": 50})).unwrap();
let area = s.execute("text.createInPath", &json!({"path": rect["id"], "mode": "area", "text": "Boxed"})).unwrap();
assert_eq!(area["bounds"], inspected(&mut s, &area["id"]));
}
/// #1748: `text.create` takes `justify`; point type then lines up on its point (centred on
/// it, or ending there), and `text.setStyle` moves it the same way afterwards.
#[test]
+84 -31
View File
@@ -5,8 +5,9 @@
//! variable, or a graph root to a `graph` variable (CSV data), put a row of values in
//! each dataset, and `dataset.select` swaps the whole document to that row in one undo
//! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name`
//! then variable names). Agents reach every command through `run_command`, like all
//! engine commands.
//! then variable names); import also reads tab-separated files and files without the
//! `name` column. Agents reach every command through `run_command`, like all engine
//! commands.
use std::collections::{BTreeMap, BTreeSet};
@@ -535,16 +536,61 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
Ok(bytes)
}
/// Parse a datasets CSV into rows of cells (no header interpretation yet): commas between cells,
/// `"` quotes a cell (doubled inside), which may then hold commas and line breaks, as
/// [`csv_escape`] writes them. An unquoted cell is trimmed. A quoted cell keeps what its quotes
/// hold as written, spaces included, and drops white space after the closing quote at the end
/// of the cell. Blank lines are skipped.
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
/// A row of a datasets CSV: the file line it starts on, and its cells.
type CsvRow = (usize, Vec<String>);
/// Whether a datasets CSV separates its cells with tabs: its header row (the first line that
/// isn't blank), read with tabs between cells, has a tab outside quotes and no comma outside
/// quotes.
fn tab_separated(text: &str) -> bool {
// `start`: nothing but `\r` read yet in this cell. `blank`: no cell on this line holds
// anything but white space yet.
let (mut in_quotes, mut start, mut blank, mut tab) = (false, true, true, false);
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if in_quotes => {
if chars.peek() == Some(&'"') {
chars.next();
blank = false;
} else {
in_quotes = false;
}
}
'"' if start => in_quotes = true,
c if in_quotes => blank &= c.is_whitespace(),
',' => return false,
'\t' => {
tab = true;
start = true;
continue;
}
'\r' => continue,
'\n' if blank => {
tab = false;
start = true;
continue;
}
'\n' => return tab,
c => blank &= c.is_whitespace(),
}
start = false;
}
tab
}
/// Parse a datasets CSV into rows of cells (no header interpretation yet). Cells are separated
/// by commas, or by tabs when [`tab_separated`] says so. `"` quotes a cell (doubled inside),
/// which may then hold separators and line breaks, as [`csv_escape`] writes them. An unquoted
/// cell is trimmed. A quoted cell keeps what its quotes hold as written, spaces included, and
/// drops white space after the closing quote at the end of the cell. Blank lines are skipped.
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<CsvRow>> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let sep = if tab_separated(text) { '\t' } else { ',' };
let (mut rows, mut row, mut cur) = (Vec::new(), Vec::<String>::new(), String::new());
// `closed`: where the closing quote of a quoted cell falls in `cur`.
let (mut in_quotes, mut closed, mut line) = (false, None, 1usize);
// `closed`: where the closing quote of a quoted cell falls in `cur`. `start`: the line the
// current row starts on.
let (mut in_quotes, mut closed, mut line, mut start) = (false, None, 1usize, 1usize);
let mut chars = text.chars().peekable();
loop {
let c = chars.next();
@@ -559,12 +605,12 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
}
}
Some('"') if cur.is_empty() => in_quotes = true,
Some(',') if !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)),
Some(ch) if ch == sep && !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)),
Some('\r') if !in_quotes => {}
Some('\n') | None if !in_quotes => {
row.push(end_csv_cell(&mut cur, &mut closed));
if row.iter().any(|c| !c.trim().is_empty()) {
rows.push(std::mem::take(&mut row));
rows.push((start, std::mem::take(&mut row)));
// +1 for the header.
if rows.len() > MAX_CSV_ROWS + 1 {
return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows")));
@@ -575,6 +621,7 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<Vec<String>>> {
return Ok(rows);
}
line += 1;
start = line;
}
None => return Err(bad(cmd, format!("line {line}: unclosed quote"))),
Some(c) => {
@@ -659,27 +706,27 @@ fn datasets_to_csv(vars: &Variables) -> String {
out
}
/// Import datasets from CSV: first column `name`, remaining headers are variable names.
/// Existing rows of the same name are replaced; new names are appended. One undo step.
/// Import datasets from CSV. A first header cell `name` makes the first column the dataset
/// names; the other headers are variable names. Without it, every header is a variable name
/// and the rows take the first free names "Data Set 1", "Data Set 2" and so on. Existing rows
/// of the same name are replaced; new names are appended. One undo step.
fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "dataset.import";
let bytes = datasets_csv_bytes(p, C)?;
let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?;
let rows = parse_datasets_csv(text, C)?;
let Some(header) = rows.first() else {
let Some((_, header)) = rows.first() else {
return Err(bad(C, "CSV is empty"));
};
if header.first().map(|h| h.trim().eq_ignore_ascii_case("name")) != Some(true) {
return Err(bad(C, "first header cell must be `name`"));
}
let name_column = header.first().is_some_and(|h| h.trim().eq_ignore_ascii_case("name"));
let (kinds, existing_names) = {
let st = s.doc()?;
let kinds: BTreeMap<String, VariableKind> = st.doc.variables.variables.iter().map(|v| (v.name.clone(), v.kind)).collect();
let existing: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
let existing: BTreeSet<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
(kinds, existing)
};
let mut columns: Vec<(usize, String, VariableKind)> = Vec::new();
for (i, h) in header.iter().enumerate().skip(1) {
for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) {
let name = h.trim();
if name.is_empty() {
return Err(bad(C, format!("empty header in column {}", i + 1)));
@@ -691,19 +738,25 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "CSV has a header but no data rows"));
}
let mut parsed: Vec<DataSet> = Vec::new();
for (r_i, row) in rows.iter().enumerate().skip(1) {
let line = r_i + 1;
let raw_name = row.first().map(|c| c.trim()).unwrap_or("");
// The names of the rows read so far, and the last number given to a row without a name.
let (mut seen, mut number) = (BTreeSet::new(), 0usize);
for &(line, ref row) in rows.iter().skip(1) {
let raw_name = if name_column { row.first().map_or("", |c| c.trim()) } else { "" };
let name = if raw_name.is_empty() {
// Same numbering as Capture Data Set when a row leaves the name blank.
(1..)
.map(|n| format!("Data Set {n}"))
.find(|n| parsed.iter().all(|d| d.name != *n) && !existing_names.iter().any(|e| e == n))
.unwrap_or_else(|| format!("Data Set {line}"))
// Same numbering as Capture Data Set when a row leaves the name blank or the file has
// no name column. The search starts after the last number given.
let (n, name) = (number + 1..)
.map(|n| (n, format!("Data Set {n}")))
.find(|(_, n)| !seen.contains(n) && !existing_names.contains(n))
.unwrap_or_else(|| (line, format!("Data Set {line}")));
number = n;
name
} else if raw_name.chars().count() > MAX_NAME {
return Err(bad(C, format!("line {line}: `name` is longer than {MAX_NAME} characters")));
} else {
named(Some(raw_name), "name", C)?
raw_name.to_string()
};
if parsed.iter().any(|d| d.name == name) {
if !seen.insert(name.clone()) {
return Err(bad(C, format!("line {line}: dataset `{name}` appears twice")));
}
let mut values = BTreeMap::new();
@@ -937,7 +990,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Data Sets…",
["Window", "Variables"],
None,
"{path? | data? | dataBase64? | csv?} load datasets from CSV (header: name, then variable names; one row per dataset). Rows whose name already exists are replaced → {imported, updated, names}",
"{path? | data? | dataBase64? | csv?} load datasets from CSV, comma- or tab-separated (header: name, then variable names; one row per dataset; without the name column every header is a variable and the rows take the first free names Data Set 1, 2…). Rows whose name already exists are replaced → {imported, updated, names}",
has_doc,
dataset_import
),
+105 -3
View File
@@ -6,7 +6,7 @@ use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, S
use crate::Session;
fn rect_json(r: Option<vectorcraft_geom::Rect>) -> Value {
pub(crate) fn rect_json(r: Option<vectorcraft_geom::Rect>) -> Value {
match r {
Some(r) => json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }),
None => Value::Null,
@@ -131,9 +131,113 @@ pub fn document(s: &Session) -> Value {
/// The same summary with [`SummaryOpts`] applied to the layer tree (artboards and
/// the rest always come whole): `depth: 0` is the skeleton — top layers with counts.
pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
document_scoped(s, opts, &Scope::default())
}
/// Which nodes `document.inspect` lists in place of the whole layer tree.
#[derive(Clone, Debug, Default)]
pub struct Scope {
/// Only these nodes (any level, already checked to exist), in the order given.
pub ids: Option<Vec<vectorcraft_doc::NodeId>>,
/// Only nodes of this panel label (case-insensitive), searched in the subtrees of `ids`
/// or else of every layer.
pub kind: Option<String>,
/// At most this many entries after `offset` (`None`: all).
pub limit: Option<u64>,
/// Entries skipped first.
pub offset: u64,
}
impl Scope {
fn scoped(&self) -> bool {
self.ids.is_some() || self.kind.is_some()
}
}
/// A node with the ids of its ancestors (layer first).
type Hit<'a> = (&'a Node, Vec<u64>);
fn ancestors(d: &Document, id: vectorcraft_doc::NodeId) -> Vec<u64> {
let mut out = Vec::new();
let mut cur = d.parent_of(id);
// The bound keeps a malformed tree from looping.
while let Some(p) = cur.filter(|_| out.len() < 10_000) {
out.push(p.0);
cur = d.parent_of(p);
}
out.reverse();
out
}
fn collect_kind<'a>(n: &'a Node, kind: &str, path: &mut Vec<u64>, out: &mut Vec<Hit<'a>>) {
if n.kind_label().eq_ignore_ascii_case(kind) {
out.push((n, path.clone()));
}
if let Some(ch) = n.children() {
path.push(n.id.0);
for c in ch.iter().rev() {
collect_kind(c, kind, path, out);
}
path.pop();
}
}
/// [`document_opts`] with the layer tree replaced by the nodes `scope` picks. Without `ids`
/// or `kind` they are the top layers (`layers`, paged by `limit`/`offset`); with either they
/// are `nodes` (each with its `path` of ancestor ids when picked by `kind`). A paged or scoped
/// reply also reports `total`, the entries before paging.
pub fn document_scoped(s: &Session, opts: SummaryOpts, scope: &Scope) -> Value {
let Some(st) = s.active() else { return Value::Null };
let d = &st.doc;
let symbols = d.symbol_boxes();
let mut hits: Vec<Hit> = Vec::new();
match (&scope.ids, scope.kind.as_deref().filter(|k| !k.is_empty())) {
(None, None) => hits.extend(d.layers.iter().rev().map(|l| (&**l, Vec::new()))),
(Some(ids), None) => hits.extend(ids.iter().filter_map(|&i| d.node(i)).map(|n| (n, Vec::new()))),
(ids, Some(kind)) => {
let mut path = Vec::new();
match ids {
Some(ids) => {
for n in ids.iter().filter_map(|&i| d.node(i)) {
path = ancestors(d, n.id);
collect_kind(n, kind, &mut path, &mut hits);
}
}
None => d.layers.iter().rev().for_each(|l| collect_kind(l, kind, &mut path, &mut hits)),
}
}
}
let total = hits.len();
let skip = usize::try_from(scope.offset).unwrap_or(usize::MAX);
let take = scope.limit.map_or(usize::MAX, |l| usize::try_from(l).unwrap_or(usize::MAX));
let by_kind = scope.kind.as_deref().is_some_and(|k| !k.is_empty());
let nodes: Vec<Value> = hits
.into_iter()
.skip(skip)
.take(take)
.map(|(n, path)| {
let member = d.parent_of(n.id).and_then(|p| d.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. }));
let mut v = node_summary_opts(n, SummaryOpts { compound_member: member, ..opts }, &symbols);
if by_kind {
v["path"] = json!(path);
}
v
})
.collect();
let mut out = document_base(s);
if let Some(o) = out.as_object_mut() {
o.insert(if scope.scoped() { "nodes" } else { "layers" }.into(), Value::Array(nodes));
if scope.scoped() || scope.limit.is_some() || scope.offset > 0 {
o.insert("total".into(), json!(total));
}
}
out
}
/// Everything `document.inspect` reports except the layer tree.
fn document_base(s: &Session) -> Value {
let Some(st) = s.active() else { return Value::Null };
let d = &st.doc;
json!({
"title": st.title(),
"path": st.path,
@@ -142,8 +246,6 @@ pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value {
"units": d.units.label(),
"colorMode": format!("{:?}", d.color_mode),
"artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::<Vec<_>>(),
// Top of the stack first, like the Layers panel.
"layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts, &symbols)).collect::<Vec<_>>(),
"currentLayer": st.active_layer.map(|l| l.0),
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
+28 -2
View File
@@ -14,6 +14,7 @@ pub mod journal;
pub mod link_watch;
pub mod namematch;
pub mod steps;
pub mod store;
mod tooling;
pub mod units;
@@ -34,6 +35,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use journal::Journal;
pub use tooling::{UiRequest, ViewInfo};
pub use vectorcraft_doc as doc;
pub use vectorcraft_doc::cancel;
pub use vectorcraft_tools as tools;
#[derive(Debug, thiserror::Error)]
@@ -50,11 +52,20 @@ pub enum EngineError {
NoNode(NodeId),
#[error("{0}")]
Other(String),
/// The caller asked for the command to stop ([`cancel`]). It changed nothing.
#[error("cancelled; nothing was changed")]
Cancelled,
/// A bug: the command panicked. The document was rolled back to its state before the command.
#[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")]
Internal { cmd: String, msg: String },
}
impl From<cancel::Cancelled> for EngineError {
fn from(_: cancel::Cancelled) -> Self {
EngineError::Cancelled
}
}
impl From<vectorcraft_doc::DocError> for EngineError {
fn from(e: vectorcraft_doc::DocError) -> Self {
EngineError::Other(e.to_string())
@@ -345,7 +356,7 @@ static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64
/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold.
/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only
/// its top-level layers open.
/// its top-level layers open (a `.ai` another app wrote opens with none open).
#[derive(Clone, Debug, Default)]
pub struct OpenRows {
ids: std::collections::HashSet<NodeId>,
@@ -1007,7 +1018,7 @@ impl Default for Prefs {
font_preview_size: s("medium"),
recent_fonts_count: 10,
missing_glyph_protection: true,
highlight_alternate_glyphs: true,
highlight_alternate_glyphs: false,
placeholder_text: true,
units_general: s("points"),
units_stroke: s("points"),
@@ -1166,6 +1177,12 @@ pub struct Session {
pub style_libraries: cmd::stylelib::Libraries,
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
pub libraries: cmd::library::Libraries,
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
/// ([`Session::attach_pdf_preset_store`]).
pdf_preset_dir: Option<std::path::PathBuf>,
/// Where the ICC profiles `color.loadProfile` loads are kept; `None`: only for the process
/// ([`Session::attach_profile_store`]).
profile_dir: Option<std::path::PathBuf>,
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
pub recent_urls: Vec<String>,
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
@@ -1198,6 +1215,8 @@ pub struct Session {
pub(crate) plane_widget_press: bool,
/// A guide being dragged out of a ruler ([`Session::ruler_guide`]).
pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>,
/// Which selected type overflows its frame, for the markers every tool but the Type tools shows.
pub(crate) overflow_marks: vectorcraft_tools::text::OverflowMarks,
/// The last search for the files of missing fonts (`text.findFontFiles`), until another
/// starts; dropping it stops it.
pub(crate) font_search: Option<cmd::fontfiles::FontSearch>,
@@ -1256,6 +1275,8 @@ impl Session {
freeform_point: None,
style_libraries: Default::default(),
libraries: Default::default(),
pdf_preset_dir: None,
profile_dir: None,
recent_urls: vec![],
ui_language: None,
journal_note: Default::default(),
@@ -1268,6 +1289,7 @@ impl Session {
liquify_stroke: None,
plane_widget_press: false,
ruler_guide: None,
overflow_marks: Default::default(),
font_search: None,
link_search: None,
search_rules: None,
@@ -1889,6 +1911,8 @@ mod tests_brushsym;
#[cfg(test)]
mod tests_build;
#[cfg(test)]
mod tests_cancel;
#[cfg(test)]
mod tests_charstroke;
#[cfg(test)]
mod tests_clip;
@@ -2115,6 +2139,8 @@ mod tests_slices;
#[cfg(test)]
mod tests_smartguides;
#[cfg(test)]
mod tests_store;
#[cfg(test)]
mod tests_strokegeom;
#[cfg(test)]
mod tests_strokegradient;
+220
View File
@@ -0,0 +1,220 @@
//! The user's own PDF presets and ICC profiles, kept in folders next to the preferences so that
//! they outlive the process (#2168, #2174). The apps decide where ([`Session::attach_pdf_preset_store`],
//! [`Session::attach_profile_store`]); a session nobody attached a folder to keeps nothing, which
//! is how the engine's own tests and embedders run.
//!
//! * **PDF presets**: one `.vcpdfpresets` file per preset (the format `pdf.preset.export` writes,
//! so a file can also be imported by hand), named after the preset. A session that attaches the
//! folder replaces its saved presets with the ones in it; a folder that doesn't exist yet takes
//! the presets the session has (the ones an older version kept in the preferences file).
//! * **ICC profiles**: the bytes of each profile `color.loadProfile` registered, as `.icc` files
//! named after the profile, never the path they came from (a profile in a system folder may
//! move or go away). Attaching the folder registers them again.
//!
//! The files are untrusted input like any other: sizes and counts are capped, a file that does not
//! parse is skipped with a log record, and every write is atomic.
use std::io::Read;
use std::path::{Path, PathBuf};
use vectorcraft_pdf::{PdfPreset, builtin_preset};
use crate::Session;
use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile};
/// Files read from one store folder, at most (the rest are ignored).
pub const MAX_STORE_FILES: usize = 1000;
/// The largest PDF presets file read (a preset is a few kilobytes).
pub const MAX_PRESET_FILE: u64 = 1 << 20;
/// The largest ICC profile file read or loaded (CMYK profiles with large tables are a few MB).
pub const MAX_PROFILE_FILE: u64 = 32 << 20;
/// The most stored ICC profile bytes read at startup, all files together (the rest are skipped).
pub const MAX_PROFILE_STORE: u64 = 256 << 20;
/// The extension of a stored ICC profile.
const PROFILE_EXT: &str = "icc";
/// The file name (without extension) a stored item called `name` gets: its name made safe for any
/// file system, then a hash of the name, so that names that differ only in characters a file name
/// can't hold, or in case, can't share a file. Names equal in any case are one item.
pub(crate) fn file_stem(name: &str) -> String {
let safe: String = name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }).take(48).collect();
// FNV-1a over the lower-cased name: stable across versions and platforms, unlike `DefaultHasher`.
let hash = name.trim().to_lowercase().bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3));
format!("{}-{hash:016x}", safe.trim())
}
/// The regular files of `dir` with the extension `ext` (any case), by file name, at most
/// [`MAX_STORE_FILES`]; none when the folder can't be read.
fn list_files(dir: &Path, ext: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else { return vec![] };
let mut files: Vec<PathBuf> = entries
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext)) && p.is_file())
.collect();
files.sort_by_key(|p| p.file_name().map(|n| n.to_string_lossy().to_lowercase()));
files.truncate(MAX_STORE_FILES);
files
}
/// The content of `path` when it is at most `max` bytes; the file is never read past `max`.
pub(crate) fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut bytes = vec![];
file.take(max + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
if bytes.len() as u64 > max {
return Err(too_large(max));
}
Ok(bytes)
}
/// What a file over its cap is told (a detail after the file's name, in the log or an error).
fn too_large(max: u64) -> String {
format!("larger than {max} bytes")
}
/// Write `bytes` to `dir/file`, making the folder; nothing is written when the file has them
/// already. A failure is logged: remembering is best effort and never fails the command.
fn put(dir: &Path, file: &str, bytes: &[u8]) -> bool {
let path = dir.join(file);
if std::fs::read(&path).is_ok_and(|old| old == bytes) {
return true;
}
let written = std::fs::create_dir_all(dir).and_then(|()| write_atomic(&path, bytes));
if let Err(e) = &written {
log::warn!("can't save {}: {e}", path.display());
}
written.is_ok()
}
#[cfg(not(target_arch = "wasm32"))]
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
vectorcraft_format::write_atomic(path, bytes)
}
/// The browser has no folders to keep anything in (the apps there never attach a store).
#[cfg(target_arch = "wasm32")]
fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> {
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system"))
}
/// Remove `dir/file`; a file that isn't there is what was wanted.
fn take(dir: &Path, file: &str) {
let path = dir.join(file);
if let Err(e) = std::fs::remove_file(&path)
&& e.kind() != std::io::ErrorKind::NotFound
{
log::warn!("can't remove {}: {e}", path.display());
}
}
impl Session {
/// Keep the PDF presets this session saves in `dir`, and take the ones already there as the
/// saved presets (see the module docs). Call it once, after the preferences were restored.
pub fn attach_pdf_preset_store(&mut self, dir: PathBuf) {
if dir.is_dir() {
self.prefs.pdf_presets = load_presets(&dir);
self.pdf_preset_dir = Some(dir);
} else {
self.pdf_preset_dir = Some(dir);
for preset in self.prefs.pdf_presets.clone() {
self.store_pdf_preset(None, &preset);
}
}
}
/// Write the saved preset `preset` to the store, which held it as `old_name` when that is set
/// (a rename). Without a store, nothing happens.
pub(crate) fn store_pdf_preset(&self, old_name: Option<&str>, preset: &PdfPreset) {
let Some(dir) = &self.pdf_preset_dir else { return };
let mut clean = preset.clone();
clean.settings.clear_passwords();
let file = format!("{}.{PRESET_FORMAT}", file_stem(&clean.name));
if let Some(old) = old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o)))
&& old != file
{
take(dir, &old);
}
match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) {
Ok(bytes) => {
put(dir, &file, &bytes);
}
Err(e) => log::warn!("can't save the PDF preset `{}`: {e}", preset.name),
}
}
/// Remove the preset `name` from the store.
pub(crate) fn unstore_pdf_preset(&self, name: &str) {
if let Some(dir) = &self.pdf_preset_dir {
take(dir, &format!("{}.{PRESET_FORMAT}", file_stem(name)));
}
}
/// Keep the ICC profiles this session loads in `dir`, and register the ones already there.
/// Call it before anything that names a profile (restoring the preferences, the first command).
pub fn attach_profile_store(&mut self, dir: PathBuf) {
let mut left = MAX_PROFILE_STORE;
for path in list_files(&dir, PROFILE_EXT) {
if left == 0 {
log::warn!("skipping the other stored colour profiles: they hold more than {MAX_PROFILE_STORE} bytes together");
break;
}
let loaded = read_capped(&path, MAX_PROFILE_FILE.min(left)).and_then(|bytes| {
left -= bytes.len() as u64;
vectorcraft_color::cms::register_icc(&bytes, None).map_err(|e| e.to_string())
});
if let Err(e) = loaded {
log::warn!("skipping the stored colour profile {}: {e}", path.display());
}
}
self.profile_dir = Some(dir);
}
/// Write the profile `name` (its file's `bytes`) to the store; `false` when it didn't go there.
pub(crate) fn store_profile(&self, name: &str, bytes: &[u8]) -> bool {
self.profile_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)), bytes))
}
/// Remove the profile `name` from the store.
pub(crate) fn unstore_profile(&self, name: &str) {
if let Some(dir) = &self.profile_dir {
take(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)));
}
}
}
/// The presets of the files in `dir`: valid ones only, no name twice (any case), none called like a
/// built-in preset, at most [`MAX_STORE_FILES`].
fn load_presets(dir: &Path) -> Vec<PdfPreset> {
let mut presets: Vec<PdfPreset> = vec![];
for path in list_files(dir, PRESET_FORMAT) {
let file = read_capped(&path, MAX_PRESET_FILE).and_then(|bytes| serde_json::from_slice::<PresetFile>(&bytes).map_err(|e| e.to_string()));
let file = match file {
Ok(f) if f.format == PRESET_FORMAT => f,
Ok(f) => {
log::warn!("skipping {}: not a PDF presets file (format `{}`)", path.display(), f.format);
continue;
}
Err(e) => {
log::warn!("skipping the stored PDF preset {}: {e}", path.display());
continue;
}
};
for mut preset in file.presets {
let name = preset.name.trim().to_string();
let usable = !name.is_empty()
&& builtin_preset(&name).is_none()
&& !presets.iter().any(|q| q.name.eq_ignore_ascii_case(&name))
&& preset.settings.check_values().is_ok()
&& presets.len() < MAX_STORE_FILES;
if usable {
preset.name = name;
preset.settings.clear_passwords();
presets.push(preset);
} else {
log::warn!("skipping the PDF preset `{name}` of {}", path.display());
}
}
}
presets
}
+59
View File
@@ -2042,6 +2042,65 @@ fn document_inspect_slices_the_layer_tree() {
assert!(s.execute("document.inspect", &json!({"depth": "deep"})).is_err());
}
/// `document.inspect` scoped by `ids`, `kind`, `limit` and `offset`: each entry is the node as
/// the full tree lists it, bad input is an error, and the unscoped reply keeps its shape.
#[test]
fn document_inspect_scopes_by_ids_kind_and_page() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0).0;
s.execute("select.all", &json!({})).unwrap();
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
let c = rect(&mut s, 40.0, 0.0, 10.0, 10.0).0;
let mut ins = |p: Value| s.execute("document.inspect", &p).unwrap();
let full = ins(json!({}));
assert!(full.get("nodes").is_none() && full.get("total").is_none(), "{full}");
let layer = full["layers"][0].clone();
let layer_id = layer["id"].as_u64().unwrap();
let find = |tree: &Value, id: u64| -> Value {
fn walk(n: &Value, id: u64) -> Option<Value> {
if n["id"] == id {
return Some(n.clone());
}
n["children"].as_array()?.iter().find_map(|c| walk(c, id))
}
walk(tree, id).unwrap()
};
// ids: just those nodes, in the order given, equal to their part of the full tree.
let some = ins(json!({"ids": [c, group]}));
assert!(some.get("layers").is_none());
assert_eq!(some["nodes"], json!([find(&layer, c), find(&layer, group)]));
assert_eq!(some["total"], 2);
assert_eq!(some["artboards"], full["artboards"]);
assert_eq!(ins(json!({"ids": [group], "depth": 0}))["nodes"][0].get("children"), None);
// kind: every match with its ancestor path, top of the stack first; paged.
let paths = ins(json!({"kind": "rectangle"}));
let ids: Vec<u64> = paths["nodes"].as_array().unwrap().iter().map(|n| n["id"].as_u64().unwrap()).collect();
assert_eq!(ids, [c, b, a]);
assert_eq!(paths["nodes"][1]["path"], json!([layer_id, group]));
assert_eq!(paths["total"], 3);
let page = ins(json!({"kind": "Rectangle", "offset": 1, "limit": 1}));
assert_eq!(page["nodes"].as_array().map(Vec::len), Some(1));
assert_eq!(page["nodes"][0]["id"], b);
assert_eq!(page["total"], 3);
assert_eq!(ins(json!({"kind": "Rectangle", "offset": 99}))["nodes"], json!([]));
assert_eq!(ins(json!({"kind": "Rectangle", "ids": [group]}))["total"], 2);
// limit/offset alone page the top layers.
let paged = ins(json!({"limit": 0}));
assert_eq!(paged["layers"], json!([]));
assert_eq!(paged["total"], 1);
for bad in
[json!({"ids": [999_999]}), json!({"ids": "all"}), json!({"ids": [-1]}), json!({"kind": 3}), json!({"limit": -1}), json!({"offset": 1.5})]
{
assert!(s.execute("document.inspect", &bad).is_err(), "{bad}");
}
}
/// `document.find` searches names, kinds and type content across the whole tree and
/// answers ids with ancestor paths plus the total, so capped replies stay explicit.
#[test]
+90
View File
@@ -0,0 +1,90 @@
//! Cancelling a long export (#2165): it stops at the next checkpoint with `EngineError::Cancelled`,
//! writes nothing, leaves the document as it was, and the session goes on working.
use std::cell::Cell;
use std::rc::Rc;
use serde_json::json;
use crate::cancel::{self, CancelToken, ProgressSink};
use crate::{EngineError, Session};
fn session(artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": artboards})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
s
}
fn revision(s: &Session) -> u64 {
s.doc().unwrap().revision
}
#[test]
fn a_pdf_export_cancelled_between_pages_stops_writes_nothing_and_changes_nothing() {
let path = vectorcraft_testkit::temp_dir("cancel-pdf").join("out.pdf");
std::fs::remove_file(&path).ok();
let mut s = session(5);
let before = (revision(&s), s.doc().unwrap().history.undo.len());
let token = CancelToken::new();
let pages = Rc::new(Cell::new(0));
// The sink runs inside the export, as the page loop reports each page: cancel on the third.
let sink: ProgressSink = {
let (token, pages) = (token.clone(), pages.clone());
Rc::new(move |done, total, _| {
// Stages before the pages report without a total.
if total != 5 {
return;
}
pages.set(done);
if done == 3 {
token.cancel();
}
})
};
let params = json!({"path": path.to_str().unwrap()});
let r = cancel::scope(&token, Some(sink), || s.execute("document.exportPdf", &params));
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
assert_eq!(pages.get(), 3, "the export stopped at the checkpoint after the cancel, not at the end");
assert!(!path.exists(), "a cancelled export writes no file");
assert_eq!((revision(&s), s.doc().unwrap().history.undo.len()), before, "the document is as it was");
// The session is fine: the same export, not cancelled, completes.
s.execute("document.exportPdf", &params).unwrap();
assert!(path.exists());
}
#[test]
fn a_cancelled_flattening_export_stops_before_the_pages() {
let mut s = session(2);
let token = CancelToken::new();
token.cancel();
// PDF/X-1a flattens transparency first; a token raised beforehand stops at the first check.
let r = cancel::scope(&token, None, || s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})));
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
assert!(s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).is_ok());
}
#[test]
fn raster_exports_and_svg_stop_when_cancelled_too() {
let dir = vectorcraft_testkit::temp_dir("cancel-formats");
let mut s = session(3);
let token = CancelToken::new();
token.cancel();
for ext in ["png", "svg", "pdf"] {
let path = dir.join(format!("out.{ext}"));
std::fs::remove_file(&path).ok();
let params = json!({"path": path.to_str().unwrap()});
let r = cancel::scope(&token, None, || s.execute("document.export", &params));
assert!(matches!(r, Err(EngineError::Cancelled)), "{ext}: {r:?}");
assert!(!path.exists(), "{ext}: nothing is written");
}
}
#[test]
fn nothing_is_cancelled_outside_a_scope() {
let token = CancelToken::new();
token.cancel();
// The token only counts for work run under it, on its thread.
let mut s = session(1);
assert!(s.execute("document.exportPdf", &json!({})).is_ok());
}
+3 -2
View File
@@ -338,8 +338,9 @@ fn template_opens_as_untitled() {
let dir = std::env::temp_dir().join(format!("vc-tpl-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("letterhead.vectorcraft");
s.execute("file.saveAsTemplate", &json!({"path": path.to_str().unwrap()})).unwrap();
s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap();
let r = s.execute("file.saveAsTemplate", &json!({"path": path.to_str().unwrap()})).unwrap();
assert_eq!(r["path"], dir.join("letterhead.vctemplate").to_str().unwrap(), "saved with the template's extension");
s.execute("document.open", &json!({"path": r["path"]})).unwrap();
let st = s.doc().unwrap();
assert!(st.path.is_none() && !st.doc.template);
assert!(st.doc.title.starts_with("Untitled-"), "{}", st.doc.title);
+61
View File
@@ -151,6 +151,67 @@ fn release_gives_the_objects_back_as_they_were() {
assert!(red(&render(&s), 50, 30));
}
/// #2540: Object › Expand turns the group into a plain group of what it draws (the rings, then the
/// part of A drawn over B as a clip group) in one undo step, looking the same; Ungroup then takes
/// it apart.
#[test]
fn expand_turns_the_group_into_plain_art_that_ungroups() {
let mut s = session();
let (g, a, b) = intertwined(&mut s);
arrange(&mut s, json!({"at": [50, 30]}));
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true));
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap();
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
let NodeKind::Group { children, clip: false } = &n.kind else { panic!("a group: {}", n.kind_label()) };
assert_eq!(children.len(), 3, "the rings, then the part of A drawn over B");
assert_eq!((children[0].id, children[1].id), (a, b), "the rings keep their ids and order");
assert!(matches!(children[2].kind, NodeKind::Group { clip: true, .. }));
assert!(children.iter().all(|c| c.extras().intertwine.is_none()), "no area is left over");
let mut ids = vec![];
n.walk(&mut |c| ids.push(c.id));
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert!(unique.len() == ids.len() && !ids.contains(&NodeId(0)), "the generated art has ids of its own: {ids:?}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [g]);
assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(false), "nothing live is left");
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
// Plain art: Ungroup takes it apart.
s.execute("object.ungroup", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g).is_none());
assert_eq!(s.doc().unwrap().selection.objects.len(), 3);
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70));
// Undo brings the Intertwine group back.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(s.doc().unwrap().doc.node(g).unwrap().kind, NodeKind::Intertwine { .. }));
}
/// Expand reaches an Intertwine group inside a selected group, and with every option on also
/// outlines the strokes of the rings and of the part drawn over the other one.
#[test]
fn expand_reaches_intertwine_groups_in_groups_and_outlines_their_strokes() {
let mut s = session();
let (g, ..) = intertwined(&mut s);
arrange(&mut s, json!({"at": [50, 30]}));
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap());
select(&mut s, &[g, c]);
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
s.execute("object.expand", &json!({})).unwrap();
let (mut intertwines, mut strokes) = (0, 0);
s.doc().unwrap().doc.node(outer).unwrap().walk(&mut |n| {
intertwines += usize::from(matches!(n.kind, NodeKind::Intertwine { .. }));
strokes += usize::from(!n.appearance.stroke_paint().is_none());
});
assert_eq!((intertwines, strokes), (0, 0));
let r = render(&s);
assert!(red(&r, 50, 30) && blue(&r, 50, 70), "it looks as it did");
let info = s.execute("object.expand.info", &json!({})).unwrap();
assert_eq!((&info["object"], &info["stroke"]), (&json!(false), &json!(false)), "nothing is left to expand");
}
#[test]
fn the_arranged_areas_move_with_the_group() {
let mut s = session();
+31 -1
View File
@@ -578,7 +578,11 @@ fn collect_merge_flatten_and_reverse() {
run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]}));
assert!(s.doc().unwrap().doc.node(col).is_none());
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer");
// A layer and its sublayer: the sublayer merges into its layer, never the other way round.
run(&mut s, "layer.merge", json!({"ids": [layer.0, sub.0]}));
assert!(s.doc().unwrap().doc.node(sub).is_none());
assert_eq!(children(&s, layer), vec![c, a, g]);
run(&mut s, "edit.undo", json!({}));
assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err());
// Flatten: other visible layers merge in, hidden ones go, templates stay.
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
@@ -595,6 +599,32 @@ fn collect_merge_flatten_and_reverse() {
assert_eq!(undos(&s), before + 1);
}
/// Merge Selected with every row highlighted (a layer, its sublayer and another layer, in panel
/// order) merges the layers into the last top-level one instead of refusing a sublayer target (#2383).
#[test]
fn merge_selected_with_all_layer_rows_highlighted_merges_into_the_last_top_level_layer() {
for reversed in [false, true] {
let mut s = session();
let (layer, sub, a, _, _, c) = tree(&mut s);
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
let d = rect(&mut s, 500.0);
// Panel order, top first: Layer 2, Layer 1, its sublayer.
let mut rows = vec![l2.0, layer.0, sub.0];
if reversed {
rows.reverse();
}
run(&mut s, "layer.highlight", json!({"ids": rows}));
let into = id(&run(&mut s, "layer.merge", json!({})));
let st = s.doc().unwrap();
assert_eq!(st.doc.layers.len(), 1, "reversed: {reversed}");
assert_eq!(into, st.doc.layers[0].id);
for n in [a, c, d] {
assert!(st.doc.layer_of(n) == Some(into), "{n:?} reversed: {reversed}");
}
assert!(st.doc.node(sub).is_some() || st.doc.node(layer).is_some() || st.doc.node(l2).is_some());
}
}
/// Collect in New Layer puts the new layer beside the topmost row when that row is directly in a
/// layer, and on top of the layer holding its group when it is in a group. When that row is in a
/// hidden or locked layer, the selected art it collects from another layer is deselected, the key
+1 -1
View File
@@ -19,7 +19,7 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId {
/// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that
/// placing and listing read).
fn png_header(w: u32, h: u32) -> Vec<u8> {
pub(crate) fn png_header(w: u32, h: u32) -> Vec<u8> {
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
let ihdr: Vec<u8> = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] {
+43
View File
@@ -736,6 +736,31 @@ fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() {
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default");
}
/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names
/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as
/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first
/// artboard.
#[test]
fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() {
let mut s = session();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap();
rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("select.all", &json!({})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
let (was, n) = (boards(&s), undo_len(&s));
for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] {
for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] {
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
}
}
let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string();
assert_eq!(e, "invalid parameters for `artboard.setProps`: missing index");
assert_eq!((boards(&s), undo_len(&s)), (was.clone(), n), "the artboards and the undo steps as they were");
s.execute("artboard.fitToSelection", &json!({})).unwrap();
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 150.0, 150.0), was[1]], "the first artboard by default");
}
// ---------- Edit Colors ----------
#[test]
@@ -2423,6 +2448,24 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() {
assert_eq!(bounds(&s, img), b);
}
#[test]
fn crop_image_trim_checks_the_rgba_size_before_decoding() {
// 1-bit gray PNGs, header only: their samples are within the decoder's limit, and as RGBA
// (4 bytes a pixel) they are just over or just within the 2 GB allowance.
let trim = |w: u32, h: u32| {
let png = super::tests_linkspanel::png_header(w, h);
let mut s = Session::new();
s.execute("document.open", &json!({"name": "wall.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.cropImage", &json!({"trim": true})).unwrap_err().to_string()
};
let over = trim(23_171, 23_170);
assert!(over.contains("an image of 23171 × 23170 pixels is too large to draw"), "{over}");
// One column fewer is within the allowance: it is decoded, and its missing rows can't be read.
let within = trim(23_170, 23_170);
assert!(within.contains("the image's pixels can't be read"), "{within}");
}
#[test]
fn crop_image_trim_leaves_a_cmyk_image_alone() {
// CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error.
+11
View File
@@ -225,6 +225,17 @@ fn saved_presets_live_in_the_preferences() {
assert!(s.execute("file.newPresets.save", &json!({"name": " "})).is_err());
}
#[test]
fn a_saved_preset_with_the_resolution_out_of_range_reads_in_range() {
let mut s = Session::new();
let saved = json!({"newDocPresets": [{"name": "Zero", "rasterEffectsPpi": 0}, {"name": "Huge", "rasterEffectsPpi": 5000}]});
s.apply_prefs(serde_json::from_value(saved).unwrap());
let ppi: Vec<Option<f64>> = presets(&mut s, "Saved").iter().map(|p| p["rasterEffectsPpi"].as_f64()).collect();
assert_eq!(ppi, [Some(1.0), Some(2400.0)]);
s.execute("file.new", &json!({"preset": "Zero"})).unwrap();
assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 1.0);
}
#[test]
fn rearrange_keeps_its_grid() {
let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3}));
+107
View File
@@ -130,6 +130,113 @@ fn duplicate_artboard_copies_its_art() {
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1);
}
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an
/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the
/// artboards, the art and the clipboard stay as they were.
#[test]
fn artboard_copies_refuse_an_index_that_names_no_artboard() {
let mut s = session();
rect(&mut s);
s.execute("artboard.new", &json!({})).unwrap();
s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap());
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] {
for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] {
let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) };
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
}
}
let st = s.doc().unwrap();
assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n));
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
}
/// Artboards 1 and 2, each with a square on it, and an artboard 3 below artboard 1 with a square on
/// it, with the Artboard tool selecting artboards 1 and 2 → the squares' ids, artboard by artboard.
fn two_selected_artboards(s: &mut Session) -> [u64; 3] {
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("artboard.new", &json!({"x": 0, "y": 700, "width": 100, "height": 100, "name": "Small"})).unwrap();
let sq = |s: &mut Session, x: u64, y: u64| {
s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()
};
let ids = [sq(s, 10, 10), sq(s, 1010, 10), sq(s, 10, 710)];
s.execute("select.none", &json!({})).unwrap();
s.select_tool("artboard", ViewInfo::default()).unwrap();
s.set_tool_option("selected", &json!([0, 1]));
ids
}
/// The objects on the document's layers, in paint order.
fn objects(s: &Session) -> Vec<u64> {
s.doc().unwrap().doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id.0)).collect()
}
/// Duplicate Artboards copies every selected artboard with its art, in one undo step (the public
/// Use the artboard context menu page). The copies keep their places relative to each other, right
/// of the last artboard.
#[test]
fn duplicate_artboards_copies_every_selected_artboard_in_one_step() {
let mut s = session();
let [a, b, _] = two_selected_artboards(&mut s);
let before = (boards(&s), objects(&s));
for p in [json!({"indices": [0, 1]}), json!({})] {
let out = s.execute("artboard.duplicate", &p).unwrap();
assert_eq!((out["index"].clone(), out["indices"].clone()), (json!(3), json!([3, 4])), "{p}");
let copies = ids(&out["ids"]);
assert_eq!(copies.len(), 2, "{p}");
assert_eq!(boards(&s)[3..], [(1120.0, "Artboard 1 copy".to_string()), (2120.0, "Artboard 2 copy".to_string())], "{p}");
assert_eq!((left(&s, copies[0]), left(&s, copies[1])), (1130.0, 2130.0), "{p}: each square on its artboard's copy");
assert!(left(&s, a) == 10.0 && left(&s, b) == 1010.0, "{p}: the originals stay");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((boards(&s), objects(&s)), before, "{p}: one undo step");
}
}
/// With the Artboard tool, Copy takes every selected artboard and the art on each (the public Cut,
/// copy, and paste artboards page). Paste adds a copy of each with its art in one undo step,
/// together right of the last artboard; Paste in Place puts them where they were. The tool then
/// selects the pasted artboards.
#[test]
fn artboard_tool_copy_and_paste_bring_every_selected_artboard() {
let mut s = session();
two_selected_artboards(&mut s);
let before = (boards(&s), objects(&s));
assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["copied"], 2, "the squares on artboards 1 and 2");
let out = s.execute("edit.paste", &json!({})).unwrap();
assert_eq!((out["artboard"].clone(), out["artboards"].clone()), (json!(3), json!([3, 4])));
assert_eq!(boards(&s)[3..], [(1120.0, "Artboard 1 copy".to_string()), (2120.0, "Artboard 2 copy".to_string())]);
let pasted = ids(&out["ids"]);
assert_eq!((left(&s, pasted[0]), left(&s, pasted[1])), (1130.0, 2130.0));
assert_eq!(s.tool_options()["selected"], json!([3, 4]), "the tool selects the pasted artboards");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((boards(&s), objects(&s)), before, "one undo step");
let out = s.execute("edit.pasteInPlace", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!((d.artboards[3].rect, d.artboards[4].rect), (d.artboards[0].rect, d.artboards[1].rect));
let pasted = ids(&out["ids"]);
assert_eq!((left(&s, pasted[0]), left(&s, pasted[1])), (10.0, 1010.0));
}
/// With the Artboard tool, Cut takes every selected artboard and the art on each away in one undo
/// step, and leaves the other artboards and their art; it fails when every artboard is selected.
#[test]
fn artboard_tool_cut_removes_every_selected_artboard() {
let mut s = session();
let [a, b, c] = two_selected_artboards(&mut s);
let before = (boards(&s), objects(&s));
assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["copied"], 2);
assert_eq!((boards(&s), objects(&s)), (vec![(0.0, "Small".into())], vec![c]));
s.execute("edit.pasteInPlace", &json!({})).unwrap();
assert_eq!(boards(&s), vec![(0.0, "Small".into()), (0.0, "Artboard 1".into()), (1000.0, "Artboard 2".into())]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((boards(&s), objects(&s)), before, "the cut is one undo step");
assert!(objects(&s).contains(&a) && objects(&s).contains(&b));
s.set_tool_option("selected", &json!([0, 1, 2]));
assert!(s.execute("edit.cut", &json!({})).is_err(), "every artboard");
assert_eq!(boards(&s), before.0);
}
/// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places
/// it right of the last artboard, Paste in Place where it was, also in another document; each paste
/// is one undo step, and the tool takes the pasted artboard.
+51
View File
@@ -336,6 +336,35 @@ fn pdf_export_expands_pattern_fills() {
assert!(rep.bytes.len() > 500);
}
#[test]
fn every_raster_export_says_when_a_pattern_holds_an_image_that_could_not_be_decoded() {
// A PNG whose header reads (so it places at its size) but whose pixel data is cut short, made
// into a pattern and then cleared from the page: only the pattern's tile draws it.
let mut s = session();
let mut png = Vec::new();
image::RgbaImage::from_pixel(20, 20, image::Rgba([200, 30, 30, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let idat = png.windows(4).position(|w| w == b"IDAT").unwrap();
png.truncate(idat + 8);
let placed = s.execute("file.place", &json!({"name": "cut.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
let placed = NodeId(placed["ids"][0].as_u64().unwrap());
sel(&mut s, &[placed]);
s.execute("object.pattern.make", &json!({"name": "Cut", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
sel(&mut s, &[placed]);
s.execute("edit.clear", &json!({})).unwrap();
assert!(doc(&s).node(placed).is_none());
let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Cut"})).unwrap();
// The first export draws the pattern's tile, and the later ones draw it from the tile kept.
for n in 0..3 {
let r = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
let warnings = r["warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("couldn't be decoded"))), "export {n}: {r}");
}
}
// ---------- repeat ----------
#[test]
@@ -587,3 +616,25 @@ fn picking_another_pattern_swatch_keeps_the_pattern_transform() {
assert_eq!(pattern, "Dots");
assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 15.0, 10.0]);
}
#[test]
fn inner_glow_on_a_pattern_fill_stays_inside_the_object_on_the_threaded_renderer() {
// #2426: the glow's pixmap was drawn with the pattern's paint transform, so it landed
// scaled at the top left instead of along the object's edges.
let mut s = session();
make_dots(&mut s);
let bg = rect(&mut s, 0.0, 0.0, 400.0, 400.0);
s.execute("paint.setFill", &json!({"ids": [bg.0], "color": "#223344"})).unwrap();
let big = rect(&mut s, 100.0, 100.0, 150.0, 150.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
sel(&mut s, &[big]);
s.execute("effect.apply", &json!({"effect": "stylize.innerGlow", "params": {"blur": 10}})).unwrap();
let mut r = vectorcraft_render::Renderer::new();
r.threads = 2;
let view = Affine::scale(1.5) * Affine::translate((-20.0, -30.0));
let out = r.render(&s.doc().unwrap().doc, 400, 400, view, &Default::default());
let [red, ..] = out.pixel(110, 100);
assert!(red < 0x30, "glow outside the object: red {red}");
let [red, ..] = out.pixel(123, 200);
assert!(red > 0x40, "no glow along the edge: red {red}");
}
+27 -13
View File
@@ -391,13 +391,22 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
("pen", Cursor::Pen),
("addAnchor", Cursor::PenAdd),
("deleteAnchor", Cursor::PenDelete),
("anchorPoint", Cursor::Pen),
("curvature", Cursor::Pen),
("mesh", Cursor::PenAdd),
("anchorPoint", Cursor::AnchorPoint),
("curvature", Cursor::Curvature),
("mesh", Cursor::MeshAdd),
("eyedropper", Cursor::Eyedropper),
("slice", Cursor::Slice),
("blend", Cursor::Blend),
("shapeBuilder", Cursor::ShapeBuilder),
("paintbrush", Cursor::Paintbrush),
("blobBrush", Cursor::BlobBrush),
("shaper", Cursor::Shaper),
("pencil", Cursor::Pencil),
("smooth", Cursor::Smooth),
("pathEraser", Cursor::PathEraser),
("join", Cursor::Join),
("eraser", Cursor::Eraser),
("knife", Cursor::Knife),
];
for (tool, c) in own {
assert_eq!(cursor(&mut s, tool), c, "{tool} off");
@@ -415,15 +424,6 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
"polygon",
"star",
"flare",
"paintbrush",
"blobBrush",
"shaper",
"pencil",
"smooth",
"pathEraser",
"join",
"eraser",
"knife",
"mirrorCut",
"lineCut",
"rectCut",
@@ -440,7 +440,7 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() {
assert_eq!(cursor(&mut s, tool), Cursor::Crosshair, "{tool} on");
}
assert_eq!(cursor(&mut s, "type"), Cursor::Text);
assert_eq!(cursor(&mut s, "scissors"), Cursor::NotAllowed);
assert_eq!(cursor(&mut s, "scissors"), Cursor::ScissorsNotAllowed);
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
for (tool, c) in own {
assert_eq!(cursor(&mut s, tool), c, "{tool} off again");
@@ -467,6 +467,8 @@ fn precise_cursors_replace_every_pen_state() {
Cursor::BlendAnchor,
Cursor::ShapeBuilder,
Cursor::ShapeBuilderErase,
Cursor::AnchorPoint,
Cursor::Curvature,
] {
assert_eq!(c.precise(), Cursor::Crosshair, "{c:?}");
}
@@ -1284,6 +1286,18 @@ fn show_font_names_in_english_preference_is_stored() {
assert!(s.prefs.font_names_in_english);
}
/// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so OpenType
/// substitutions are not pink on every canvas until the user turns the preference on.
#[test]
fn highlight_alternate_glyphs_preference_is_off_by_default() {
let mut s = Session::new();
assert!(!s.prefs.highlight_alternate_glyphs, "off by default");
set_pref(&mut s, "highlightAlternateGlyphs", json!(true));
assert!(s.prefs.highlight_alternate_glyphs);
set_pref(&mut s, "highlightAlternateGlyphs", json!(false));
assert!(!s.prefs.highlight_alternate_glyphs);
}
/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator
/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`.
#[test]
+60
View File
@@ -66,6 +66,66 @@ fn print_writes_the_saved_setup_with_overrides() {
let _ = std::fs::remove_file(&path);
}
/// A document with four 300 × 200 artboards, set up to print two copies, and a saved print preset
/// "Sheet" that prints artboard 4 only.
fn four_artboards_and_a_preset() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 4})).unwrap();
s.execute("print.setup", &json!({"settings": {"copies": 2}})).unwrap();
s.execute("print.presets.save", &json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap();
s
}
#[test]
fn print_takes_a_print_preset_by_name() {
let mut s = four_artboards_and_a_preset();
// The preset's settings stand in for the document's: one copy of artboard 4.
let (bytes, v) = print(&mut s, json!({"preset": "sheet"}));
assert_eq!((v["pages"].clone(), vectorcraft_pdf::info(&bytes, None).unwrap().pages.len()), (json!(1), 1));
let (_, v) = print(&mut s, json!({"preset": "Sheet", "settings": {"copies": 3}}));
assert_eq!(v["pages"], 3, "settings apply over the preset's");
let (_, v) = print(&mut s, json!({"preset": "[Default]"}));
assert_eq!(v["pages"], 4, "[Default]: every artboard, one copy");
assert_eq!(s.execute("print.setup", &json!({})).unwrap()["settings"]["copies"], 2, "printing doesn't change the saved setup");
// A PostScript file too.
let v = s.execute("file.print", &json!({"preset": "Sheet", "format": "postscript"})).unwrap();
assert_eq!(v["pages"], 1);
}
#[test]
fn an_unknown_print_preset_is_refused_with_the_presets() {
let mut s = four_artboards_and_a_preset();
for c in ["file.print", "print.preview", "print.setup"] {
// Agents (MCP, the control channel) may pass `preset`.
s.check_params(c, &json!({"preset": "Sheet", "settings": {}})).unwrap();
let e = s.execute(c, &json!({"preset": "Nope"})).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{c}: {e}");
assert!(e.to_string().contains("no print preset named `Nope` (presets: [Default], Sheet)"), "{c}: {e}");
assert!(matches!(s.execute(c, &json!({"preset": 4})), Err(EngineError::BadParams { .. })), "{c}");
}
assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 2);
}
#[test]
fn setup_with_a_preset_stores_its_settings() {
let mut s = four_artboards_and_a_preset();
let undo = s.doc().unwrap().history.undo.len();
let v = s.execute("print.setup", &json!({"preset": "Sheet"})).unwrap();
let got = (v["settings"]["copies"].clone(), v["settings"]["artboards"].clone(), v["settings"]["range"].clone());
assert_eq!(got, (json!(1), json!("range"), json!("4")));
let stored = s.doc().unwrap().doc.print_setup.clone().unwrap();
assert_eq!(stored, v["settings"]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// The same preset again is no new step; settings apply over it.
s.execute("print.setup", &json!({"preset": "Sheet"})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let v = s.execute("print.setup", &json!({"preset": "Sheet", "settings": {"collate": false}})).unwrap();
assert_eq!((v["settings"]["range"].clone(), v["settings"]["collate"].clone()), (json!("4"), json!(false)));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["copies"], 2);
}
#[test]
fn preview_lists_pages_tiles_inks_and_warnings() {
let (mut s, _) = session();
+14
View File
@@ -52,6 +52,20 @@ fn pages_place_the_artboard_on_the_trim_box() {
}
}
#[test]
fn a_print_preset_lays_out_its_settings_with_settings_over_them() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 4})).unwrap();
s.execute("print.setup", &json!({"settings": {"media": "a4"}})).unwrap();
s.execute("print.presets.save", &json!({"name": "Sheet", "settings": {"artboards": "range", "range": "4"}})).unwrap();
let v = s.execute("print.preview", &json!({"preset": "Sheet", "settings": {"artboards": "range", "range": "3"}})).unwrap();
assert_eq!((v["pages"].clone(), v["sheets"][0]["artboard"].clone()), (json!(1), json!(2)), "{v}");
assert_eq!(v["settings"]["media"], "letter", "the preset's paper, not the document's");
let v = s.execute("print.preview", &json!({"preset": "Sheet"})).unwrap();
assert_eq!((v["pages"].clone(), v["sheets"][0]["artboard"].clone()), (json!(1), json!(3)));
assert_eq!(s.doc().unwrap().doc.print_setup.as_ref().unwrap()["media"], "a4", "the preview keeps nothing");
}
/// Point type at (`x`, `y`) in `family` → its id.
fn type_in(s: &mut Session, x: f64, y: f64, family: &str) -> u64 {
s.execute("text.create", &json!({"x": x, "y": y, "text": "Ab", "font": family, "style": "Regular"})).unwrap()["id"].as_u64().unwrap()
+100
View File
@@ -2,6 +2,7 @@
//! raster effect images they make (PDF export, Expand Appearance) and the Rasterize defaults.
use serde_json::{Value, json};
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
use vectorcraft_doc::{Background, Document, NodeId, NodeKind, RasterColorModel, RasterEffectsSettings};
use super::*;
@@ -98,6 +99,60 @@ fn add_around_grows_the_effect_image() {
assert_eq!((after.width() - before.width(), after.height() - before.height()), (72, 72));
}
/// The transforms of the images in `doc` flattened for output.
fn image_transforms(doc: &Document) -> Vec<[f64; 6]> {
let mut xfs = vec![];
crate::flatten_raster_effects(doc).unwrap().walk(|n| {
if let NodeKind::Image(im) = &n.kind {
xfs.push(im.xf.as_coeffs());
}
});
xfs
}
#[test]
fn a_file_with_add_around_out_of_range_opens_with_it_in_range() {
let mut s = session();
shadowed(&mut s);
let mut d = (*s.doc().unwrap().doc).clone();
d.raster_effects.add_around = 1e300;
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap();
assert_eq!(back.raster_effects.add_around, MAX_ADD_AROUND);
let xfs = image_transforms(&back);
assert!(!xfs.is_empty() && xfs.iter().flatten().all(|c| c.is_finite()), "{xfs:?}");
// 1,000 pt on every side at 72 ppi.
assert_eq!(effect_image(&back).width() - effect_image(&s.doc().unwrap().doc).width(), 2000);
d.raster_effects.add_around = -5.0;
assert_eq!(vectorcraft_format::load(&vectorcraft_format::save_file(&d)).unwrap().raster_effects.add_around, 0.0);
}
#[test]
fn art_too_large_for_f64_gets_no_effect_image() {
let mut s = session();
let r = shadowed(&mut s);
// Settings out of range in a document made without opening a file.
let mut d = (*s.doc().unwrap().doc).clone();
d.raster_effects.add_around = 1e300;
assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new());
let mut d = (*s.doc().unwrap().doc).clone();
let NodeKind::Path { path, live, .. } = &mut d.node_mut(r).unwrap().kind else { panic!("a path") };
(*path, *live) = (vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(-1e300, -1e300, 1e300, 1e300)), None);
assert_eq!(image_transforms(&d), Vec::<[f64; 6]>::new());
// Expand Appearance of it by id, with nothing selected, puts no image into the document (the
// rectangle at its own size gets one).
let normal = (*s.doc().unwrap().doc).clone();
let mut expanded_images = |d: Document| {
let st = s.doc_mut().unwrap();
(st.doc, st.selection) = (std::sync::Arc::new(d), Default::default());
s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap();
let mut images = 0;
s.doc().unwrap().doc.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
images
};
assert_eq!(expanded_images(normal), 1);
assert_eq!(expanded_images(d), 0);
}
#[test]
fn the_color_model_and_anti_alias_reach_the_pixels() {
let mut s = session();
@@ -158,3 +213,48 @@ fn raster_settings_round_trip_and_old_files_load() {
let back = vectorcraft_format::load(&bytes).unwrap();
assert_eq!((back.raster_effects_ppi, back.raster_effects), (72.0, RasterEffectsSettings::default()));
}
/// Save the active document with the raster effects resolution `ppi` and open the file.
fn reopen_with_ppi(s: &mut Session, ppi: f64) {
let mut d = (*s.doc().unwrap().doc).clone();
d.raster_effects_ppi = ppi;
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
s.execute("document.open", &json!({"name": "ppi.vectorcraft", "dataBase64": data})).unwrap();
}
#[test]
fn a_file_with_the_resolution_out_of_range_opens_with_it_in_range() {
for (saved, opened, pixels) in [(0.0, 1.0, 1), (-5.0, 1.0, 1), (5000.0, 2400.0, 334), (f64::MAX, 2400.0, 334)] {
let mut s = session();
let r = id(&s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap());
s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap();
reopen_with_ppi(&mut s, saved);
assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, opened, "saved as {saved}");
assert_eq!(s.execute(C, &json!({})).unwrap()["resolution"], opened);
assert_eq!(s.execute("document.info", &json!({})).unwrap()["document"]["rasterEffectsPpi"], opened);
// Rasterize without a ppi param renders the 10 pt square at it.
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let out = s.execute("object.rasterize", &json!({})).unwrap();
assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(pixels), Some(pixels)), "saved as {saved}");
}
}
#[test]
fn a_gradient_exports_at_the_resolution_the_file_opens_with() {
for saved in [0.0, -5.0] {
let mut s = session();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 720, "height": 720})).unwrap());
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [r.0]})).unwrap();
reopen_with_ppi(&mut s, saved);
// EMF writes the gradient as an image at the raster effects resolution: 720 pt at 1 ppi.
let region = vectorcraft_geom::Rect::new(0.0, 0.0, 720.0, 720.0);
let out = vectorcraft_metafile::export(&s.doc().unwrap().doc, region, vectorcraft_metafile::Kind::Emf).unwrap();
let mut sizes = vec![];
vectorcraft_metafile::import(&out.bytes).unwrap().document.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
sizes.push((im.width, im.height));
}
});
assert_eq!(sizes, [(10, 10)], "saved as {saved}");
}
}
+128
View File
@@ -0,0 +1,128 @@
//! The stores of PDF presets and ICC profiles (#2168, #2174): what one session saves, the next one
//! finds; damaged or oversized files are skipped, never fatal.
use serde_json::{Value, json};
use vectorcraft_color::cms;
use crate::Session;
use crate::store::{MAX_PRESET_FILE, file_stem};
fn fresh_dir(tag: &str) -> std::path::PathBuf {
let dir = vectorcraft_testkit::temp_dir(&format!("store-{tag}"));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn saved_names(s: &mut Session) -> Vec<String> {
let v = s.execute("pdf.preset.list", &json!({})).unwrap();
v["presets"].as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn saved_presets_come_back_in_the_next_session_and_renames_and_deletes_follow() {
let dir = fresh_dir("presets").join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Press", "description": "for the printer", "preset": "Press Quality"})).unwrap();
a.execute("pdf.preset.save", &json!({"name": "Web"})).unwrap();
a.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Screen"})).unwrap();
a.execute("pdf.preset.save", &json!({"name": "Old"})).unwrap();
a.execute("pdf.preset.delete", &json!({"name": "Old"})).unwrap();
let mut b = Session::new();
b.attach_pdf_preset_store(dir.clone());
assert_eq!(saved_names(&mut b), ["Press", "Screen"]);
let press = b.prefs.pdf_presets.iter().find(|p| p.name == "Press").unwrap();
assert_eq!(press.description, "for the printer");
assert_eq!(press.settings, b.pdf_presets().into_iter().find(|p| p.name == "Press Quality").unwrap().settings);
// The file of a name that is gone is gone.
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
assert!(!dir.join(format!("{}.vcpdfpresets", file_stem("Old"))).exists());
}
#[test]
fn a_folder_that_does_not_exist_yet_takes_the_presets_the_preferences_held() {
let dir = fresh_dir("seed").join("PDF Presets");
let mut a = Session::new();
a.execute("pdf.preset.save", &json!({"name": "From Preferences"})).unwrap();
a.attach_pdf_preset_store(dir.clone());
assert!(dir.is_dir(), "written at once");
let mut b = Session::new();
b.attach_pdf_preset_store(dir);
assert_eq!(saved_names(&mut b), ["From Preferences"]);
}
#[test]
fn damaged_unsafe_and_oversized_preset_files_are_skipped() {
let dir = fresh_dir("damaged").join("PDF Presets");
let mut a = Session::new();
a.attach_pdf_preset_store(dir.clone());
a.execute("pdf.preset.save", &json!({"name": "Good"})).unwrap();
let write = |name: &str, bytes: &[u8]| std::fs::write(dir.join(name), bytes).unwrap();
write("a-not-json.vcpdfpresets", b"{ not json");
write("b-other-format.vcpdfpresets", br#"{"format": "something else", "presets": []}"#);
let huge = vec![b' '; MAX_PRESET_FILE as usize + 1];
write("c-huge.vcpdfpresets", &huge);
write("d-readme.txt", b"ignored");
let good = std::fs::read_to_string(dir.join(format!("{}.vcpdfpresets", file_stem("Good")))).unwrap();
// A built-in name, an empty name, a second "good" in another case, and values no export accepts.
let mut file: Value = serde_json::from_str(&good).unwrap();
let mut preset = file["presets"][0].clone();
for name in ["High Quality Print", " ", "GOOD"] {
preset["name"] = json!(name);
file["presets"].as_array_mut().unwrap().push(preset.clone());
}
preset["name"] = json!("Bad Values");
preset["settings"]["advanced"]["fontSubsetPercent"] = json!(500);
file["presets"].as_array_mut().unwrap().push(preset);
write("e-mixed.vcpdfpresets", file.to_string().as_bytes());
let mut b = Session::new();
b.attach_pdf_preset_store(dir);
assert_eq!(saved_names(&mut b), ["Good"]);
}
/// An ICC file of an RGB profile named `name` (a built-in one with its description replaced by a
/// name of the same length).
fn icc_named(name: &str) -> Vec<u8> {
let builtin = cms::DISPLAY_P3;
assert_eq!(builtin.len(), name.len());
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
let mut bytes = cms::icc_bytes(builtin).unwrap().to_vec();
let (from, to) = (utf16(builtin), utf16(name));
let at = bytes.windows(from.len()).position(|w| w == from).unwrap();
bytes[at..at + to.len()].copy_from_slice(&to);
bytes
}
#[test]
fn stored_profiles_register_again_and_removing_one_removes_its_copy() {
let dir = fresh_dir("profiles").join("Color Profiles");
std::fs::create_dir_all(&dir).unwrap();
let name = "StoreRGB-1";
let file = format!("{}.icc", file_stem(name));
std::fs::write(dir.join("junk.icc"), b"not a profile").unwrap();
std::fs::write(dir.join("empty.icc"), b"").unwrap();
std::fs::write(dir.join(&file), icc_named(name)).unwrap();
assert!(cms::profile(name).is_none());
let mut s = Session::new();
s.attach_profile_store(dir.clone());
assert!(cms::profile(name).is_some_and(|p| !p.builtin), "registered from the store");
// A profile loaded from elsewhere is copied in, whatever the file is called.
let other = "StoreRGB-2";
let loose = vectorcraft_testkit::temp_dir("store-loose").join("whatever.icm");
std::fs::write(&loose, icc_named(other)).unwrap();
let r = s.execute("color.loadProfile", &json!({"path": loose.to_str().unwrap()})).unwrap();
assert_eq!((r["name"].as_str(), r["remembered"].as_bool()), (Some(other), Some(true)));
std::fs::remove_file(&loose).unwrap();
assert_eq!(std::fs::read(dir.join(format!("{}.icc", file_stem(other)))).unwrap(), icc_named(other));
let r = s.execute("color.removeProfile", &json!({"name": name})).unwrap();
assert_eq!(r["removed"], name);
assert!(cms::profile(name).is_none() && !dir.join(&file).exists());
assert!(s.execute("color.removeProfile", &json!({"name": name})).is_err(), "already gone");
assert!(s.execute("color.removeProfile", &json!({"name": cms::SRGB})).is_err(), "built-in profiles stay");
s.execute("color.removeProfile", &json!({"name": other})).unwrap();
}
+20
View File
@@ -737,3 +737,23 @@ fn a_colour_previewed_while_typing_keeps_the_typing() {
s.tool_text("d", v).unwrap();
assert_eq!(obj(&s, id).plain_text(), "abcd");
}
/// The red out port of selected overflowing area type is drawn with the Selection tool too, and
/// not twice with the Type tool, which draws it itself (#2323).
#[test]
fn overflow_marker_shows_with_the_selection_tool() {
let mut s = session();
let v = ViewInfo::default();
let long = "Words that fill more than the frame holds. ".repeat(20);
let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": long, "size": 20, "area": {"width": 100, "height": 40}})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.select_tool("selection", v).unwrap();
let red = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Path { color: [230, 30, 30], .. })).count();
assert_eq!(red(&mut s), 1, "the out port");
s.select_tool("type", v).unwrap();
assert_eq!(red(&mut s), 1, "the Type tool's own, not a second one");
s.execute("select.set", &json!({"ids": []})).unwrap();
s.select_tool("selection", v).unwrap();
assert_eq!(red(&mut s), 0, "nothing selected");
}
+65 -1
View File
@@ -948,6 +948,70 @@ fn datasets_csv_keeps_spaces_in_quoted_cells() {
assert_eq!(value("C"), text_value(" quoted "));
}
/// A header row with a tab and no comma outside quotes makes tabs the separator; a quoted cell
/// may then hold tabs and commas. A header row with a comma outside quotes keeps commas, even
/// when a cell holds a tab. A tab-separated header with an unclosed quote fails with the reason.
#[test]
fn datasets_csv_reads_tab_separated_files() {
let mut s = session();
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
s.execute("variable.define", &json!({"name": "Last, First", "kind": "text"})).unwrap();
let tsv = "name\tTitle\tShow\r\nA\tTwo words\ttrue\r\nB\t\"a, b\tc\"\tfalse\r\n";
let imp = s.execute("dataset.import", &json!({"csv": tsv})).unwrap();
assert_eq!(imp["names"], json!(["A", "B"]));
s.execute("dataset.import", &json!({"csv": "name,Title\nC,one\ttwo"})).unwrap();
s.execute("dataset.import", &json!({"csv": "name\t\"Last, First\"\nD\tSmith, Jo"})).unwrap();
let value = |name: &str, var: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get(var)).cloned();
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
assert_eq!(value("A", "Title"), text_value("Two words"));
assert_eq!(value("A", "Show"), Some(vectorcraft_doc::DataValue::Visible(true)));
assert_eq!(value("B", "Title"), text_value("a, b\tc"));
assert_eq!(value("B", "Show"), Some(vectorcraft_doc::DataValue::Visible(false)));
assert_eq!(value("C", "Title"), text_value("one\ttwo"));
assert_eq!(value("D", "Last, First"), text_value("Smith, Jo"));
let unclosed = s.execute("dataset.import", &json!({"csv": "name\tTitle\t\"Show\nA\tx\ttrue"})).unwrap_err().to_string();
assert!(unclosed.contains("unclosed quote"), "{unclosed}");
}
/// Without a `name` column every header cell is a variable, and the rows take the first free
/// names Data Set 1, 2 and so on. A first header cell `name`, in any case, still holds the names.
#[test]
fn datasets_csv_without_a_name_column_numbers_the_rows() {
let mut s = session();
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("dataset.new", &json!({"name": "Data Set 2", "values": {"Title": "kept"}})).unwrap();
let imp = s.execute("dataset.import", &json!({"csv": "Title\nx\ny\n"})).unwrap();
assert_eq!((imp["imported"].as_u64(), imp["updated"].as_u64()), (Some(2), Some(0)));
assert_eq!(imp["names"], json!(["Data Set 1", "Data Set 3"]));
let value = |s: &Session, name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Title")).cloned();
let text_value = |v: &str| Some(vectorcraft_doc::DataValue::Text(v.into()));
assert_eq!(value(&s, "Data Set 1"), text_value("x"));
assert_eq!(value(&s, "Data Set 2"), text_value("kept"));
assert_eq!(value(&s, "Data Set 3"), text_value("y"));
let named = s.execute("dataset.import", &json!({"csv": "NAME,Title\nData Set 1,z"})).unwrap();
assert_eq!((named["imported"].as_u64(), named["updated"].as_u64()), (Some(0), Some(1)));
assert_eq!(value(&s, "Data Set 1"), text_value("z"));
}
/// An error about a row's values or name gives the file line the row starts on, counting blank
/// lines and line breaks inside quoted cells.
#[test]
fn datasets_csv_row_errors_name_the_file_line() {
let mut s = session();
s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap();
s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap();
let error = |s: &mut Session, csv: &str| s.execute("dataset.import", &json!({"csv": csv})).unwrap_err().to_string();
let blank_line = error(&mut s, "name,Show\n\nX,maybe");
assert!(blank_line.contains("line 3:"), "{blank_line}");
let line_break = error(&mut s, "name,Title,Show\nA,\"one\ntwo\",true\nB,x,maybe");
assert!(line_break.contains("line 4:"), "{line_break}");
let twice = error(&mut s, "name,Title\nA,x\n\nA,y");
assert!(twice.contains("line 4:"), "{twice}");
let long = error(&mut s, &format!("name,Title\n\n{},x", "n".repeat(256)));
assert!(long.contains("line 3:") && long.contains("longer than 255"), "{long}");
}
/// Import / export datasets as CSV: header `name` then variables; round-trip keeps values.
#[test]
fn datasets_csv_round_trips_and_rejects_bad_input() {
@@ -989,7 +1053,7 @@ fn datasets_csv_round_trips_and_rejects_bad_input() {
assert_eq!((again["imported"].as_u64(), again["updated"].as_u64()), (Some(0), Some(2)));
assert!(s.execute("dataset.import", &json!({"csv": "name,Ghost\nX,y"})).is_err());
assert!(s.execute("dataset.import", &json!({"csv": "name,Show\nX,maybe"})).is_err());
assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "first column must be name");
assert!(s.execute("dataset.import", &json!({"csv": "Title,Name\nX,y"})).is_err(), "no variable named Title");
let out = dir.file("sets.csv");
s.execute("dataset.export", &json!({"path": out})).unwrap();
+6
View File
@@ -399,6 +399,12 @@ impl Session {
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx));
// The Type tools draw the overflow markers of selected type themselves.
if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") {
let mut marks = std::mem::take(&mut self.overflow_marks);
v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::overflow_markers(&mut marks, cx)));
self.overflow_marks = marks;
}
if let Some(g) = self.ruler_guide.take() {
v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx)));
self.ruler_guide = Some(g);
+7
View File
@@ -189,6 +189,11 @@ fn every_command_with_junk_params() {
fn structured_junk() {
let cases: Vec<(&str, Value)> = vec![
("select.set", json!({"ids": [0, u64::MAX, -1, "x", null, 1.5]})),
("document.export", json!({"format": "png", "rect": [1e308, -1e308, 1e308, 1e308]})),
("document.export", json!({"format": "png", "rect": [0, 0, 1e300, 1e300], "scale": 64})),
("document.export", json!({"format": "tiff", "rect": [null, "x", -1, 0.0000001], "useArtboards": true})),
("document.export", json!({"format": "bmp", "rect": {"x": 1}, "artboard": 3})),
("document.export", json!({"format": "svg", "rect": [0, 0, 10, 10]})),
("select.add", json!({"ids": [[1], {"a": 1}]})),
("select.anchors", json!({"id": 2, "anchors": [[99, 99], [-1, 0], ["a", "b"]], "mode": "set"})),
("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 1000]]}, null, 5]})),
@@ -270,6 +275,8 @@ fn structured_junk() {
("stroke.set", json!({"weight": -5, "dash": [0, 0, 0], "miterLimit": -1})),
("stroke.set", json!({"weight": 1e308, "dash": [-1, -2], "dashOffset": 1e308})),
("stroke.set", json!({"dash": [1e-12]})),
("document.inspect", json!({"ids": [u64::MAX, 0, 1e308], "kind": "", "limit": u64::MAX, "offset": u64::MAX})),
("document.inspect", json!({"ids": null, "kind": {"a": 1}, "limit": -1, "offset": "x", "depth": u64::MAX})),
("transparency.set", json!({"opacity": -50})),
("transparency.set", json!({"opacity": 1e308, "blend": "NoSuchBlend"})),
("perspective.move", json!({"from": [0, 0], "to": [1e308, -1e308], "perpendicular": true, "copy": true, "plane": "ground"})),
+63
View File
@@ -389,3 +389,66 @@ fn unknown_dimension_options_are_refused_by_commands_but_a_newer_file_still_open
let doc = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap();
assert!(doc.node(NodeId(id)).unwrap().extras().dimension.is_some());
}
/// With Shift down, the dashed line from the first picked point follows the 45-degree step the
/// second click will pick (#2432), not the raw pointer.
#[test]
fn shift_shows_the_constrained_line_while_picking_the_second_point() {
use vectorcraft_engine::tools::Overlay;
let mut s = session();
s.select_tool("dimension", ViewInfo::default()).unwrap();
ev(&mut s, PointerKind::Down, 300.0, 300.0);
ev(&mut s, PointerKind::Up, 300.0, 300.0);
let end = |s: &mut Session, shift: bool| {
let e = PointerEvent::new(PointerKind::Move, 372.0, 310.0).with_mods(Mods { shift, ..Mods::default() });
s.pointer(&e, ViewInfo::default()).unwrap();
s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap()
};
assert_eq!(end(&mut s, false).y, 310.0);
assert!((end(&mut s, true).y - 300.0).abs() < 1e-9);
}
/// A click near a line, away from its anchors, snaps to the closest point of the line (#2432).
#[test]
fn clicking_near_a_line_snaps_the_measured_point_onto_it() {
let mut s = session();
edge(&mut s);
s.select_tool("dimension", ViewInfo::default()).unwrap();
for (x, y) in [(130.0, 103.0), (150.0, 97.0), (140.0, 130.0)] {
ev(&mut s, PointerKind::Down, x, y);
ev(&mut s, PointerKind::Up, x, y);
}
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!(text(&s, id), "20.00 pt");
}
/// Both picked points snap onto anchors a few pixels away, the rubber band and a ring show the
/// snap before the click, and the dimension stays linked to the anchors (#2516).
#[test]
fn both_points_snap_onto_anchors_with_a_visible_indicator() {
use vectorcraft_engine::tools::Overlay;
let mut s = session();
let line = edge(&mut s);
s.select_tool("dimension", ViewInfo::default()).unwrap();
ev(&mut s, PointerKind::Move, 103.0, 102.0);
let ring = |s: &mut Session| {
s.overlays(ViewInfo::default())
.into_iter()
.any(|o| matches!(o, Overlay::Anchor { p, size, .. } if size > 8.0 && p.x == 100.0 && p.y == 100.0))
};
assert!(ring(&mut s), "ring on the anchor before the first click");
ev(&mut s, PointerKind::Down, 103.0, 102.0);
ev(&mut s, PointerKind::Up, 103.0, 102.0);
ev(&mut s, PointerKind::Move, 169.0, 98.0);
let end = s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Line { b, .. } = o { Some(b) } else { None }).unwrap();
assert_eq!((end.x, end.y), (172.0, 100.0), "rubber band ends on the anchor");
ev(&mut s, PointerKind::Down, 169.0, 98.0);
ev(&mut s, PointerKind::Up, 169.0, 98.0);
ev(&mut s, PointerKind::Down, 136.0, 130.0);
ev(&mut s, PointerKind::Up, 136.0, 130.0);
let id = s.doc().unwrap().selection.objects[0].0;
assert_eq!(text(&s, id), "72.00 pt");
run(&mut s, "select.set", json!({"ids":[line]}));
run(&mut s, "object.scale", json!({"sx":200,"sy":100}));
assert_eq!(text(&s, id), "144.00 pt", "linked to both anchors");
}
+79 -2
View File
@@ -1157,6 +1157,69 @@ proptest! {
}
}
// ---------- data set CSV files ----------
/// A document with a text, a visibility and a graph variable, each bound to an object, and two
/// data sets.
fn merge_session() -> vectorcraft_engine::Session {
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let id = |r: Value| r["id"].as_u64().unwrap();
let t = id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One"})).unwrap());
let r = id(s.execute("shape.rectangle", &json!({"x": 10, "y": 40, "width": 50, "height": 40})).unwrap());
let graph = json!({"type": "column", "x": 80, "y": 10, "width": 100, "height": 80, "csv": ",Sales\nA,1\nB,2"});
let g = id(s.execute("graph.create", &graph).unwrap());
for (name, kind, object) in [("Title", "text", t), ("Show", "visibility", r), ("Chart", "graph", g)] {
s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap();
s.execute("variable.bind", &json!({"variable": name, "ids": [object]})).unwrap();
}
s.execute("dataset.new", &json!({"name": "A", "values": {"Title": "Two, \"quoted\"\nlines", "Show": true, "Chart": ",Q\nX,3"}})).unwrap();
s.execute("dataset.new", &json!({"name": "B", "values": {"Title": " padded ", "Show": false}})).unwrap();
s
}
/// [`merge_session`]'s data sets exported as CSV, made once.
fn saved_datasets() -> String {
static TEXT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
TEXT.get_or_init(|| merge_session().execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string()).clone()
}
/// `dataset.import` with `params` on [`merge_session`]'s document, the first three data sets it
/// read selected in turn and every data set exported again; the document then renders and exports.
fn datasets(what: &str, params: Value) -> Result<(), TestCaseError> {
survive(what, || {
let mut s = merge_session();
if let Ok(r) = no_panic(s.execute("dataset.import", &params)) {
for name in r["names"].as_array().into_iter().flatten().take(3) {
let _ = no_panic(s.execute("dataset.select", &json!({"name": name})));
}
}
let _ = no_panic(s.execute("dataset.export", &json!({})));
Some((*s.doc().ok()?.doc).clone())
})
}
proptest! {
#![proptest_config(config())]
#[test]
fn dataset_csv_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "name,Title,Show,Chart\n", "Title\tShow\tChart\n", "name\tTitle\n", "Show,Chart\n"])) {
datasets("data set CSV garbage", json!({"csv": format!("{head}{s}")}))?;
}
#[test]
fn dataset_csv_bytes_never_panic(bytes in prop::collection::vec(any::<u8>(), 0..300)) {
datasets("data set CSV bytes", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)}))?;
}
#[test]
fn mutated_dataset_csvs_never_panic(cut in 0usize..200, edits in prop::collection::vec(arb_edit(), 0..10), tabs in any::<bool>()) {
let text = saved_datasets();
let text = if tabs { text.replace(',', "\t") } else { text };
datasets("mutated data set CSV", json!({"csv": mutate_text(&text, cut, &edits)}))?;
}
}
// ---------- shortcut sets ----------
/// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an
@@ -1240,6 +1303,17 @@ fn raster_samples() -> Vec<(&'static str, Vec<u8>)> {
out.push((name, b));
}
out[0].1 = vectorcraft_engine::cmd::fileio::ppi::with_png_resolution(&out[0].1, (300.0, 150.0));
// TIFFs that store one plane per channel: CMYK with a channel after the inks, and gray with
// alpha, 16 bits a sample.
let plane = |v: u16| vec![v; 15];
let cmyk = [plane(200), plane(40), plane(10), plane(0), plane(90)];
let cmyk =
vectorcraft_testkit::tiff::Tiff { width: 5, height: 3, photometric: 5, bits: 8, extra: &[0], planar: true, ppi: Some(300), channels: &cmyk };
out.push(("planar-cmyk.tif", vectorcraft_testkit::tiff::tiff(&cmyk)));
let gray = [plane(30_000), plane(65_535)];
let gray =
vectorcraft_testkit::tiff::Tiff { width: 5, height: 3, photometric: 1, bits: 16, extra: &[2], planar: true, ppi: None, channels: &gray };
out.push(("planar-gray.tif", vectorcraft_testkit::tiff::tiff(&gray)));
// A layered PSD with transparency (as VectorCraft exports it), and a PackBits PSB by hand.
let mut s = rich_session();
let r = s.execute("document.export", &json!({"format": "psd", "ppi": 2})).unwrap();
@@ -1262,7 +1336,7 @@ proptest! {
/// Mutated image headers (resolution metadata, chunk and segment lengths) never crash reading
/// their resolution or placing them.
#[test]
fn mutated_images_place_without_panics(which in 0usize..6, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::<u8>()), 0..12)) {
fn mutated_images_place_without_panics(which in 0usize..8, cut in 0usize..4000, edits in prop::collection::vec((0usize..120, any::<u8>()), 0..12)) {
let (name, mut bytes) = raster_samples().swap_remove(which);
for (at, b) in edits {
if let Some(x) = bytes.get_mut(at) {
@@ -1932,12 +2006,13 @@ proptest! {
// ---------- print presets files ----------
/// Import print presets from `data`, then lay out (and print, when short) the rich document with
/// each one imported.
/// each one imported, by its settings and by its name.
fn print_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "print.presets.import", data, |s, r| {
let list = no_panic(s.execute("print.presets.list", &json!({}))).unwrap_or_default();
for name in r["imported"].as_array().into_iter().flatten() {
let Some(p) = list["presets"].as_array().into_iter().flatten().find(|p| &p["name"] == name) else { continue };
let _ = no_panic(s.execute("print.preview", &json!({ "preset": name })));
let settings = json!({ "settings": p["settings"] });
let pages = no_panic(s.execute("print.preview", &settings)).ok().and_then(|v| v["pages"].as_u64());
let _ = no_panic(s.execute("print.setup", &settings));
@@ -2468,6 +2543,8 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"1 /Bool (BSDashAdj) ,",
"0.75 /Real (opac) ,",
"1e308 /Real (blur) ,",
"1 /Int (csrc) ,",
"-1e308 /Real (dark) ,",
"0 /Real ,",
"; (BSVariableWidthTValuesArray) ,",
"; (BSCenteredWidthsArray) ,",
+52
View File
@@ -44,6 +44,58 @@ fn effect_params_no_longer_taken_still_replay_and_open() {
assert_eq!(stored(&back)["blurr"], json!(3));
}
/// An effect's `color` that the effect can't read (a string `Color::from_hex` doesn't read, an
/// `[r, g, b]` without three numbers, an object that isn't a color, `null`) is refused by
/// `effect.apply`, `appearance.addEffect` and `effect.setParams` themselves, not only by the check,
/// and the document and its undo history stay as they were. The forms the effect reads pass. A
/// document saved with an unreadable color still opens with it.
#[test]
fn an_effect_color_it_cannot_draw_is_refused() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"color": "#ff0000"}})).unwrap();
let state = |s: &Session| {
let st = s.active().unwrap();
(st.doc.clone(), st.history.undo.len())
};
let before = state(&s);
let why = "`color` must be a #rrggbb color";
for bad in [json!("not-a-color"), json!("#ff00"), json!([1, 0]), json!(["#ff0000", 0, 0]), json!({"gray": 50}), json!(null)] {
for (cmd, params) in [
("effect.setParams", json!({"index": 0, "params": {"color": bad}})),
("effect.apply", json!({"effect": "stylize.outerGlow", "params": {"color": bad}})),
("appearance.addEffect", json!({"effect": "adjust.shiftToColor", "params": {"color": bad}})),
] {
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains(why), "{cmd} {params}: {e}");
let checked = s.check_params(cmd, &params).unwrap_err().to_string();
assert!(checked.contains(why), "{cmd} {params}: {checked}");
assert!(state(&s) == before, "{cmd} {params} changed the document or its undo history");
}
}
let stored = |s: &Session| s.active().unwrap().doc.layers[0].children().unwrap()[0].appearance.effects[0].params["color"].clone();
for good in [
json!("#00ff00"),
json!("#0f0"),
json!("00ff00"),
json!("#00ff00ff"),
json!("green"),
json!([0, 1, 0]),
json!({"model": "rgb", "r": 0.0, "g": 1.0, "b": 0.0}),
json!({"model": "cmyk", "c": 1.0, "m": 0.0, "y": 1.0, "k": 0.0}),
] {
let params = json!({"index": 0, "params": {"color": good}});
s.check_params("effect.setParams", &params).unwrap_or_else(|e| panic!("{good}: {e}"));
s.execute("effect.setParams", &params).unwrap_or_else(|e| panic!("{good}: {e}"));
assert_eq!(stored(&s), good);
}
let mut doc = (*s.active().unwrap().doc).clone();
doc.node_mut(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["color"] = json!("not-a-color");
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.layers[0].children().unwrap()[0].appearance.effects[0].params["color"], json!("not-a-color"));
}
/// What a command adds to its own journal entry passes the check, so a recorded journal can be
/// sent back through the control channel or MCP.
#[test]
+1 -1
View File
@@ -102,7 +102,7 @@ fn alt_at_another_endpoint_reports_conversion_and_preserves_objects_through_undo
assert_eq!(node(&s, b), &before_b);
assert_eq!(s.doc().unwrap().history.undo.len(), before_undo + 1);
// Check the captured cursor last: a mismatch must not hide the actual conversion/history controls.
assert_eq!(advertised, Cursor::PenConvert, "Alt cursor must describe the conversion actually performed, not Join");
assert_eq!(advertised, Cursor::AnchorPoint, "Alt cursor must describe the conversion actually performed, not Join");
}
#[test]
+18 -2
View File
@@ -96,8 +96,24 @@ impl MeshReader {
let [n0, n1, n2, n3] = corners else { return None };
// (anchor, towards the next corner, towards the previous one, colour, opacity)
let read = |n: &Vec<f64>| -> Option<(Point, Vec2, Vec2, Color, f32)> {
let comps = n.len().checked_sub(9)?;
let &[opacity, x, y, nx, ny, _, px, py, _] = n.get(comps..)? else { return None };
// Older editing streams omit opacity; the colour space determines the number
// of colour components, so a coordinate must never be mistaken for opacity.
let comps = match self.space.as_str() {
"DeviceRGB" => 3,
"DeviceCMYK" => 4,
"DeviceGray" => 1,
_ => return None,
};
let (opacity, geometry) = match n.get(comps..)? {
geometry if geometry.len() == 8 => (1.0, geometry),
[opacity, geometry @ ..] if geometry.len() == 8 => (*opacity, geometry),
_ => return None,
};
let &[x, y, nx, ny, nw, px, py, pw] = geometry else { return None };
// Non-unit rational weights need a different surface representation.
if nw != 1.0 || pw != 1.0 {
return None;
}
if ![opacity, x, y, nx, ny, px, py].iter().all(|v| v.is_finite()) {
return None;
}
+2
View File
@@ -66,6 +66,8 @@ pub(super) struct Obj {
pub entries: Vec<(Option<String>, Vec<V>)>,
/// Values closed without a key.
pub content: Vec<V>,
/// Vector art enclosed by `X=` … `X+` in an opacity-mask dictionary.
pub art: Option<std::sync::Arc<vectorcraft_doc::Node>>,
}
impl Obj {
+78 -13
View File
@@ -272,6 +272,7 @@ enum Kind {
Group,
Compound,
Clip,
Mask,
Obj(Box<Obj>),
}
@@ -290,6 +291,8 @@ struct Frame {
base: usize,
/// The clipping paths' indexes among the children.
clips: Vec<usize>,
/// A compound member group explicitly declares its subpaths' winding with `D`.
compound_members: bool,
}
/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands
@@ -383,6 +386,8 @@ struct Reader<'a> {
clip_fills: HashSet<NodeId>,
/// Groups closed as an object's drawn look (`9 () XW`).
looks: HashSet<NodeId>,
/// Legacy compound member groups, even when followed by the generic `9 () XW` marker.
compound_groups: HashSet<NodeId>,
/// The gradient mesh being read (`/Mesh X!` … `/End X!`), and whether it started on a `%_` line.
mesh: Option<(super::mesh::MeshReader, bool)>,
/// The art styles (`%AI9_BeginArtStyles`), by name.
@@ -425,9 +430,6 @@ const IGNORED: &[&str] = &[
"Xh",
"XH",
"XF",
"D",
"X=",
"X+",
"Bc",
"XP",
"Np",
@@ -529,6 +531,7 @@ impl<'a> Reader<'a> {
hidden_ids: HashSet::new(),
clip_fills: HashSet::new(),
looks: HashSet::new(),
compound_groups: HashSet::new(),
group_ended: false,
layer_state: None,
styles: HashMap::new(),
@@ -785,7 +788,16 @@ impl<'a> Reader<'a> {
self.ensure_space();
}
let base = if matches!(kind, Kind::Obj(_)) { self.stack.len() } else { self.frames.last().map_or(0, |f| f.base) };
self.frames.push(Frame { kind, children: Vec::new(), hidden, after: self.after_object, gs: self.gs.clone(), base, clips: Vec::new() });
self.frames.push(Frame {
kind,
children: Vec::new(),
hidden,
after: self.after_object,
gs: self.gs.clone(),
base,
clips: Vec::new(),
compound_members: false,
});
self.after_object = false;
Ok(())
}
@@ -836,7 +848,7 @@ impl<'a> Reader<'a> {
"*U" => self.close(what, |k| matches!(k, Kind::Compound))?,
_ => self.close(what, |k| matches!(k, Kind::Clip))?,
};
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, .. } = f;
let Frame { kind, children: kids, gs, clips, hidden: opened_hidden, compound_members, .. } = f;
// A group holding only clipping paths (`q … u <path> W n U … Q`) doesn't clip on its own: `u`/`U` don't save
// the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it.
if matches!(kind, Kind::Group)
@@ -879,6 +891,9 @@ impl<'a> Reader<'a> {
if clipping && let Some(f) = self.frames.last_mut() {
f.clips.push(f.children.len());
}
if compound_members {
self.compound_groups.insert(node.id);
}
self.add(node, opened_hidden || hidden)?;
if let Some(p) = painted {
self.clip_fills.insert(p.id);
@@ -994,6 +1009,16 @@ impl<'a> Reader<'a> {
return Ok(());
}
";" => return self.end_obj(),
"X=" => {
if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(o), .. }) if o.ty == "Mask") {
// `X=` also introduces legacy text frame art, read by `text_frames`.
return Ok(());
}
self.end_path_quietly();
return self.open(Kind::Mask, hidden);
}
"X+" if self.frames.iter().any(|f| matches!(f.kind, Kind::Mask)) => return self.end_mask(),
"X+" => return Ok(()),
_ => {}
}
// What follows an object (its name, transparency and style) ends with anything else. The
@@ -1105,6 +1130,19 @@ impl<'a> Reader<'a> {
}
}
"d" => self.dash(&vals),
"D" => {
// The published Adobe Illustrator File Format Specification (version 3.0), §6.8,
// permits `u` groups of compound members.
// Their explicit winding declarations describe subpaths, not separate paints;
// modern streams can also end these groups with `9 () XW`.
let n = self.frames.len();
if n >= 2
&& self.frames.get(n - 2).is_some_and(|f| matches!(f.kind, Kind::Compound))
&& let Some(f) = self.frames.last_mut().filter(|f| matches!(f.kind, Kind::Group))
{
f.compound_members = true;
}
}
// Transparency and state.
"Xy" => self.transparency(&vals),
// A group's own state (`Xd`, `6 () XW`) without an `Xy` keeps the current transparency, as
@@ -1224,6 +1262,23 @@ impl<'a> Reader<'a> {
Ok(())
}
/// Capture mask art in document coordinates, without adding it to the owner's siblings.
fn end_mask(&mut self) -> Result<(), String> {
self.end_path_quietly();
let f = self.close("X+", |k| matches!(k, Kind::Mask))?;
let (children, clip) = self.clip_first(f.children, &f.clips);
let art = self.new_node(NodeKind::Group { children, clip }, &f.gs);
self.gs = f.gs;
self.after_object = f.after;
let Some(Frame { kind: Kind::Obj(o), .. }) = self.frames.last_mut() else {
return Err("mask art ends outside a mask dictionary".into());
};
if o.art.replace(Arc::new(art)).is_some() {
return Err("a mask has more than one art block".into());
}
Ok(())
}
/// `;`: the innermost dictionary closes.
fn end_obj(&mut self) -> Result<(), String> {
if !matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) {
@@ -1379,13 +1434,19 @@ impl<'a> Reader<'a> {
/// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the
/// opacity mask it may carry.
fn art_dictionary(&mut self, o: &Obj) {
// A mask (`/Mask`, keyed `AI9_Mask`): its art is on `X=` … `X+` lines, and drawing the
// object without it shows what the mask hides, so it must be named (#2004). A mask that
// is disabled changes nothing.
if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask"))
&& mask.nums("Disabled").first().copied() != Some(1.0)
{
self.unreadable("opacity masks");
if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) {
if let Some(art) = &mask.art {
let on = |key: &str| mask.nums(key).first().copied() == Some(1.0);
let value =
OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") };
if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) {
n.extras_mut().mask = Some(Box::new(value));
} else if !on("Disabled") {
self.unreadable("opacity masks without an owning object");
}
} else if mask.nums("Disabled").first().copied() != Some(1.0) {
self.unreadable("opacity masks without readable art");
}
}
let name = match o.text("AIArtName") {
Some(name) => name,
@@ -1542,7 +1603,11 @@ impl<'a> Reader<'a> {
let style = vals.iter().rev().find_map(V::text).unwrap_or_default();
let after = std::mem::take(&mut self.after_object);
if code == 9.0
&& let Some(look) = self.frames.last().and_then(|f| f.children.last()).filter(|n| matches!(n.kind, NodeKind::Group { .. }))
&& let Some(look) = self
.frames
.last()
.and_then(|f| f.children.last())
.filter(|n| matches!(n.kind, NodeKind::Group { .. }) && !self.compound_groups.contains(&n.id))
{
self.looks.insert(look.id);
}
+55 -8
View File
@@ -330,9 +330,8 @@ fn style(filters: &[Filter]) -> ArtStyle {
params: json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}),
visible: true,
}),
SHADOW if param("csrc").unwrap_or(0.0) == 0.0 => effects.push(Effect {
id: "stylize.dropShadow".into(),
params: json!({
SHADOW => {
let mut params = json!({
"mode": mode(1),
"opacity": param("opac").unwrap_or(0.75) * 100.0,
"blur": 2.0 * param("blur").unwrap_or(5.0),
@@ -341,11 +340,15 @@ fn style(filters: &[Filter]) -> ArtStyle {
"x": param("horz").unwrap_or(7.0),
"y": param("vert").unwrap_or(7.0),
"color": color,
}),
visible: true,
}),
// A shadow darkening the object's own colors is not read as Drop Shadow's Darkness.
SHADOW => plain = false,
});
// A `csrc` of 1 is Darkness, the object's own colors with black added; any value other than 0 is read as
// Darkness. `dark` is the percent of black (0 to 100, where `opac` is a fraction), and 100 when it is missing or not finite.
if param("csrc").unwrap_or(0.0) != 0.0 {
params["shade"] = json!("darkness");
params["darkness"] = json!(param("dark").filter(|v| v.is_finite()).map_or(100.0, |v| v.clamp(0.0, 100.0)));
}
effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
}
_ => {}
}
}
@@ -393,6 +396,50 @@ mod tests {
assert_eq!((shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), (Some(7.0), Some(7.0)));
}
/// The style `Shade`: a Drop Shadow whose dictionary holds `keys`.
fn shadow_style(keys: &str) -> ArtStyle {
let data = format!(
"/KnownStyle :\n(Shade) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{keys}; /Dict ;\n /Part ,\n;\n"
);
art_styles(data.as_bytes()).remove("Shade").unwrap_or_default()
}
#[test]
fn a_drop_shadow_with_darkness_reads_its_darkness() {
// GUARDS: a `csrc` other than 0 made the style not plain, and the object came in as its drawn look.
let style = shadow_style("1 /Int (blnd) ,\n0.75 /Real (opac) ,\n5 /Real (blur) ,\n1 /Int (csrc) ,\n50 /Real (dark) ,\n");
assert!(style.plain, "{style:?}");
let [shadow] = style.effects.as_slice() else { panic!("{style:?}") };
let p = &shadow.params;
assert_eq!(
(shadow.id.as_str(), &p["shade"], &p["darkness"], &p["opacity"], &p["blur"]),
("stylize.dropShadow", &json!("darkness"), &json!(50.0), &json!(75.0), &json!(10.0))
);
}
#[test]
fn a_drop_shadow_darkness_is_100_percent_when_missing_and_kept_within_0_to_100() {
for (dark, want) in [("", 100.0), ("(fifty) /Real (dark) ,\n", 100.0), ("250 /Real (dark) ,\n", 100.0), ("-5 /Real (dark) ,\n", 0.0)] {
let style = shadow_style(&format!("1 /Int (csrc) ,\n{dark}"));
assert!(style.plain, "{dark:?}: {style:?}");
let [shadow] = style.effects.as_slice() else { panic!("{dark:?}: {style:?}") };
assert_eq!((&shadow.params["shade"], &shadow.params["darkness"]), (&json!("darkness"), &json!(want)), "{dark:?}");
}
}
#[test]
fn a_drop_shadow_with_color_keeps_its_params() {
// GUARDS: a `csrc` of 0, or none, is Color; its shadow has neither `shade` nor `darkness`.
for csrc in ["", "0 /Int (csrc) ,\n50 /Real (dark) ,\n"] {
let style = shadow_style(&format!("2 /Int (blnd) ,\n0.5 /Real (opac) ,\n{csrc}"));
assert!(style.plain, "{csrc:?}: {style:?}");
let [shadow] = style.effects.as_slice() else { panic!("{csrc:?}: {style:?}") };
let want = json!({"mode": "Screen", "opacity": 50.0, "blur": 10.0, "x": 7.0, "y": 7.0, "color": vectorcraft_color::Color::BLACK});
assert_eq!(shadow.params, want, "{csrc:?}");
}
}
#[test]
fn hostile_dash_offsets_and_width_profiles_are_tamed() {
let mut f = Filter::default();
+93 -2
View File
@@ -809,8 +809,7 @@ fn what_it_doesnt_read_is_an_error_where_it_shows() {
assert!(e.contains("pattern fills"), "{e}");
let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err();
assert!(e.contains("symbols"), "{e}");
// An opacity mask (#2004): drawing the layer's art whole would show what the mask hides, so
// the mask is named and the file is read from its PDF part instead.
// A mask with no owner must still be reported (#2004).
let mask = "%_/ArtDictionary :\n%_/XMLUID : (M) ; (AI10_ArtUID) ,\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_0 /Bool (Disabled) ,\n%_X=\n\
%_0 0 0 1 k\n%_0 0 m\n%_5 0 L\n%_5 5 L\n%_0 5 L\n%_0 0 L\n%_f\n%_X+\n%_ (Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_; (AI9_Mask) ,\n%_;\n";
let e = read(stream(&layer(mask)).as_bytes()).unwrap_err();
@@ -1177,6 +1176,26 @@ fn a_styled_object_inside_a_drawn_look_survives_it() {
assert_eq!((shadow.id.as_str(), shadow.params["x"].as_f64(), shadow.params["y"].as_f64()), ("stylize.dropShadow", Some(3.0), Some(4.0)));
}
/// An object whose style's Drop Shadow has Darkness (`csrc` 1, `dark` 50) comes in as itself with
/// that shadow, not as its drawn look.
#[test]
fn an_object_with_a_darkness_shadow_comes_in_as_itself() {
let styles = ART_STYLES.replace("3 /Real (horz) ,\n", "1 /Int (csrc) ,\n50 /Real (dark) ,\n3 /Real (horz) ,\n");
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!("0 Ae\nu\n{RED}{}f\nU\n%_{hidden_rect}f\n1 (Blue) XW\n", rect_path(0.0, 0.0, 10.0, 10.0));
let r = read_ok(&format!("{styles}{}", layer(&art_)));
assert!(!r.warnings.iter().any(|w| w.contains("drawn look")), "{:?}", r.warnings);
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "the object, not its look: {a:#?}");
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(0.0, 0.0, 1.0)));
let [shadow] = a[0].appearance.effects.as_slice() else { panic!("{:?}", a[0].appearance.effects) };
assert_eq!(
(shadow.id.as_str(), shadow.params["shade"].as_str(), shadow.params["darkness"].as_f64(), shadow.params["x"].as_f64()),
("stylize.dropShadow", Some("darkness"), Some(50.0), Some(3.0))
);
}
/// A styled object read in place of its drawn look keeps the transparency written on the look (the
/// object's own, after the look's group ends): here 60 % in Multiply.
#[test]
@@ -1219,3 +1238,75 @@ fn a_gradient_mesh_reads_as_a_patch() {
let e = read(stream(&layer(&mesh("Separation"))).as_bytes()).unwrap_err();
assert!(e.contains("gradient meshes"), "{e}");
}
/// Both mesh-corner layouts must keep colour, position and handles, rather than guessing
/// opacity from a coordinate. Unsupported weights and truncated corners remain errors.
#[test]
fn mesh_corners_with_and_without_opacity() {
let geometry = ["10 20 10 25 1 15 20 1", "10 50 15 50 1 10 45 1", "40 50 40 45 1 35 50 1", "40 20 35 20 1 40 25 1"];
for (space, colour) in [("DeviceRGB", "0.2 0.4 0.6"), ("DeviceCMYK", "0.1 0.2 0.3 0.4"), ("DeviceGray", "0.5")] {
let mesh = |opacity: &str| {
let corners: String = geometry.iter().map(|g| format!("%_[{colour} {opacity}{g}] /N X#\n")).collect();
format!("/Mesh X!\n%_/{space} /CS X#\n{corners}%_/E X#\n/End X!\n")
};
let old = art(&read_ok(&layer(&mesh(""))).doc);
let new = art(&read_ok(&layer(&mesh("1 "))).doc);
assert_eq!(old[0].kind, new[0].kind);
let translucent = art(&read_ok(&layer(&mesh("0.25 "))).doc);
let NodeKind::Mesh(m) = &translucent[0].kind else { panic!("mesh") };
assert!(m.points.iter().all(|p| p.opacity == 0.25));
assert_eq!(m.points[0].p, Point::new(10.0, 80.0));
for broken in [mesh("").replace("10 25 1", "10 25 0.5"), mesh("").replace("15 20 1]", "15 20]")] {
assert!(read(stream(&layer(&broken)).as_bytes()).unwrap_err().contains("gradient meshes"));
}
}
}
#[test]
fn vector_opacity_mask_keeps_its_art_and_does_not_paint_as_a_sibling() {
let owner = format!("{RED}{}f\n", rect_path(10.0, 20.0, 50.0, 60.0));
let mask = "%_/ArtDictionary :\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_X=\n%_u\n%_1 g\n%_10 20 m\n%_10 60 L\n%_30 60 L\n%_30 20 L\n%_f\n%_U\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_0 /Bool (Inverted) ,\n%_0 /Bool (Disabled) ,\n%_; (AI9_Mask) ,\n%_;\n";
let following = format!("{}f\n", rect_path(70.0, 20.0, 80.0, 30.0));
for (invert, disabled, linked) in [(false, false, true), (true, false, false), (false, true, true)] {
let mask = mask
.replace("0 /Bool (Inverted)", &format!("{} /Bool (Inverted)", u8::from(invert)))
.replace("0 /Bool (Disabled)", &format!("{} /Bool (Disabled)", u8::from(disabled)))
.replace("1 /Bool (Linked)", &format!("{} /Bool (Linked)", u8::from(linked)));
let r = read_ok(&layer(&format!("{owner}{mask}{following}")));
assert!(r.warnings.is_empty());
let a = art(&r.doc);
assert_eq!(a.len(), 2);
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)), "mask colour must not leak");
let m = a[0].extras().mask.as_ref().unwrap();
assert_eq!((m.clip, m.invert, m.disabled, m.linked), (true, invert, disabled, linked));
let mask_path = &m.art.children().unwrap()[0].children().unwrap()[0];
assert!(matches!(mask_path.kind, NodeKind::Path { .. }));
assert_eq!(mask_path.geometric_bounds(), Some(Rect::new(10.0, 40.0, 30.0, 80.0)));
assert_ne!(mask_path.id, a[0].id);
}
for broken in [mask.replace("%_X+\n", ""), mask.replace("%_U\n", ""), mask.replace("%_f", "%_unknownMaskOperator")] {
assert!(read(stream(&layer(&format!("{owner}{broken}"))).as_bytes()).is_err());
}
}
/// `9 () XW` also follows legacy compound member groups. The final gradient paints
/// every member; the initial black paint is a placeholder, not a separate black object.
#[test]
fn winding_declared_compound_members_keep_the_final_gradient() {
let defs = "%AI5_BeginGradient: (G)\n(G) 0 2 Bd\n0 0 0 0 1 0 0 2 50 0 Bs\n0 0 0 0 0 0 1 2 50 100 Bs\nBD\n%AI5_EndGradient\n";
let body = format!(
"*u\nu\n1 D\n0 g\n{}f\n0 D\n{}Bb\n1 (G) 0 0 0 100 1 0 0 1 0 0 Bg\nf\n0 BB\nU\n9 () XW\n*U\n",
rect_path(10.0, 10.0, 80.0, 80.0),
rect_path(30.0, 30.0, 50.0, 50.0)
);
let r = read_ok(&format!("{defs}{}", layer(&body)));
let a = art(&r.doc);
assert_eq!(a.len(), 1);
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("compound, not separately painted look") };
assert_eq!(children.len(), 2);
assert!(matches!(fill(&a[0]), Paint::Gradient(g) if g.gradient.stops.len() == 2));
assert!(children.iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
// Without winding declarations this is still an independently painted appearance group.
let r = read_ok(&format!("{defs}{}", layer(&body.replace("1 D\n", "").replace("0 D\n", ""))));
assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }));
}
+102 -7
View File
@@ -703,6 +703,44 @@ fn compare_scale(r: Rect) -> f64 {
(400.0 / r.width().max(r.height()).max(1.0)).min(4.0)
}
/// A flattened print page made only of images. Sampling it directly at 400 pixels aliases
/// its fine edges while vector art receives coverage antialiasing. Compare both at a higher
/// resolution and apply the same reconstruction filter; keep the existing difference limits.
fn comparison_samples(doc: &Document) -> u32 {
let (mut images, mut vector) = (false, false);
doc.walk(|n| match n.kind {
NodeKind::Image(_) => images = true,
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {}
_ => vector = true,
});
if images && !vector { 4 } else { 1 }
}
/// Render for the page check. The comparison scale bounds the long side to 400 px, so the
/// extra samples stay bounded to 1600 px. Filter premultiplied channels, including alpha.
fn comparison_render(
renderer: &mut vectorcraft_render::Renderer,
doc: &Document,
rect: Rect,
scale: f64,
samples: u32,
) -> vectorcraft_render::Rendered {
let rendered = renderer.render_region(doc, rect, scale * f64::from(samples), false);
if samples == 1 {
return rendered;
}
let (width, height) = match vectorcraft_render::raster_size(rect, scale) {
Ok(size) => size,
// A malformed region cannot provide a comparable raster.
Err(_) => return rendered,
};
let Some(src) = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.pixels) else {
return vectorcraft_render::Rendered { width: 0, height: 0, pixels: Vec::new() };
};
let pixels = image::imageops::resize(&src, width, height, image::imageops::FilterType::Triangle).into_raw();
vectorcraft_render::Rendered { width, height, pixels }
}
/// The page drawn once, to weigh the layers' art against it: [`Self::difference`] draws the layers
/// with a renderer of their own, which keeps their decoded images between draws (the page's images
/// can share their keys and not their pixels, so the two don't share one).
@@ -712,6 +750,7 @@ struct PageView {
page_type: Vec<Rect>,
layers: vectorcraft_render::Renderer,
scale: f64,
samples: u32,
}
impl PageView {
@@ -720,10 +759,11 @@ impl PageView {
let scale = compare_scale(ra);
// Drawn on nothing and then seen on white: blending modes have no page to blend with, as in
// the app that wrote the file.
let page = vectorcraft_render::Renderer::new().render_region(frame.page, ra, scale, false);
let samples = comparison_samples(frame.page);
let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples);
let mut page_type = vec![];
type_areas(&frame.page.layers, ra, scale, &mut page_type);
Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale }
Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale, samples }
}
/// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]).
@@ -731,7 +771,7 @@ impl PageView {
// The page doesn't print what isn't printed.
let printed = as_printed(b);
let b = printed.as_ref().unwrap_or(b);
let y = self.layers.render_region(b, rb, self.scale, false);
let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples);
let mut areas = self.page_type.clone();
type_areas(&b.layers, rb, self.scale, &mut areas);
compared(&self.page, &y, &areas)
@@ -743,7 +783,7 @@ impl PageView {
fn colour_difference(&mut self, b: &Document, rb: Rect) -> f64 {
let printed = as_printed(b);
let b = printed.as_ref().unwrap_or(b);
let y = self.layers.render_region(b, rb, self.scale, false);
let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples);
if (y.width, y.height) != (self.page.width, self.page.height) {
return 1.0;
}
@@ -759,8 +799,9 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference {
let b = printed.as_ref().unwrap_or(b);
let scale = compare_scale(ra);
// A renderer each: the two documents' images can share their keys and not their pixels.
let x = vectorcraft_render::Renderer::new().render_region(a, ra, scale, false);
let y = vectorcraft_render::Renderer::new().render_region(b, rb, scale, false);
let samples = comparison_samples(a);
let x = comparison_render(&mut vectorcraft_render::Renderer::new(), a, ra, scale, samples);
let y = comparison_render(&mut vectorcraft_render::Renderer::new(), b, rb, scale, samples);
let mut areas = vec![];
type_areas(&a.layers, ra, scale, &mut areas);
type_areas(&b.layers, rb, scale, &mut areas);
@@ -772,7 +813,7 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference {
/// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents,
/// and what the comparison looks for is art that is missing or out of place.
fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference {
if x.width != y.width || x.height != y.height {
if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height {
return Difference { any: 1.0, solid: 1.0 };
}
let differs: Vec<bool> = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect();
@@ -1358,4 +1399,58 @@ mod tests {
let texts: Vec<String> = parts.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
assert_eq!(texts, ["Hello", "abcd"]);
}
/// A flattened page samples a fine stripe pattern once per comparison pixel; that
/// aliases to white although vector coverage is gray. Shared minification avoids the
/// false fallback, while genuinely missing art must still be rejected.
#[test]
fn raster_page_comparison_filters_minification_without_accepting_missing_art() {
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, ImageBlob, ImageObject};
let mut vector = Document::new(1000.0, 500.0);
let layer = vector.layers[0].id;
for i in 0..400 {
let x = f64::from(i) * 2.5 + 0.5;
let n = Node::path(
vector.alloc_id(),
vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 1.0, 500.0)),
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
vector.insert(Some(layer), usize::MAX, n).unwrap();
}
let rect = vector.artboards[0].rect;
let source = vectorcraft_render::Renderer::new().render_region(&vector, rect, 1.6, true);
let mut png = Vec::new();
image::RgbaImage::from_raw(source.width, source.height, source.to_straight())
.unwrap()
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let blob = ImageBlob::new("image/png", png);
let key = blob.content_key();
let mut page = Document::new(1000.0, 500.0);
page.images.insert(key.clone(), blob);
let image = Node::new(
page.alloc_id(),
NodeKind::Image(Box::new(ImageObject {
key,
width: source.width,
height: source.height,
xf: Affine::scale(1.0 / 1.6),
link: None,
placement: Default::default(),
})),
);
page.insert(Some(page.layers[0].id), usize::MAX, image).unwrap();
assert_eq!(comparison_samples(&page), 4);
assert_eq!(comparison_samples(&vector), 1, "ordinary vector pages retain their comparison");
let mut renderer = vectorcraft_render::Renderer::new();
let a = comparison_render(&mut renderer, &page, rect, 0.4, 1);
let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1);
assert!(compared(&a, &b, &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page");
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
let mut view = PageView::new(&frame);
assert!(compare(&frame, &mut view, &vector).is_ok());
assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0)).is_err(), "missing artwork remains an error");
let shifted = Rect::new(0.0, 0.0, 500.0, 500.0);
assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error");
}
}
+48
View File
@@ -994,3 +994,51 @@ fn a_copied_area_type_object_is_framed_where_its_own_art_path_is() {
let b = frame.bounds().unwrap();
assert!((b.x0 - 30.0).abs() < 1e-6 && (b.y0 - 30.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "{b:?}");
}
/// A raster print page can carry a fully vector editing copy. The legacy mesh has no
/// opacity operand and its vector mask hides half of it: ignoring either loses this import.
#[test]
fn raster_print_page_recovers_legacy_mesh_vector_mask_and_editable_curve() {
let mesh = "/Mesh X!\n%_/DeviceRGB /CS X#\n\
%_[0 0 1 20 20 20 30 1 30 20 1] /N X#\n\
%_[0 0 1 20 60 30 60 1 20 50 1] /N X#\n\
%_[0 0 1 60 60 60 50 1 50 60 1] /N X#\n\
%_[0 0 1 60 20 50 20 1 60 30 1] /N X#\n%_/E X#\n/End X!\n";
let mask = "%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_1 g\n%_20 20 m\n%_20 60 L\n%_40 60 L\n%_40 20 L\n%_f\n%_X+\n%_(Art) ,\n%_1 /Bool (Clipping) ,\n%_1 /Bool (Linked) ,\n%_; (AI9_Mask) ,\n%_;\n";
let curve = "0 1 1 0 1 0 0 Xa\n70 20 m\n70 50 90 50 90 20 c\nf\n";
let data = native(&layer("Editable", true, &format!("u\n{mesh}{mask}{curve}U\n")));
let samples = ("0000ff".repeat(20) + &"ffffff".repeat(20)).repeat(40);
let page = format!(
"gsave 20 20 translate 40 40 scale /DeviceRGB setcolorspace \
<< /ImageType 1 /Width 40 /Height 40 /BitsPerComponent 8 /Decode [0 1 0 1 0 1] \
/ImageMatrix [40 0 0 -40 0 40] /DataSource <{samples}> >> image grestore \
1 0 0 setrgbcolor 70 20 moveto 70 50 90 50 90 20 curveto closepath fill"
);
// Use AI9's zlib container, too, rather than only the newer Zstandard test helper.
use std::io::Write as _;
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(data.as_bytes()).unwrap();
let packed = ascii85(&encoder.finish().unwrap());
let file = format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n%{packed}\n%AI9_PrivateDataEnd\n"
);
let r = import(file.as_bytes()).unwrap();
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
assert_eq!(names(&r.document), ["Editable"]);
let group = &r.document.layers[0].children().unwrap()[0];
let a = group.children().unwrap();
assert_eq!(a.len(), 2);
assert!(matches!(a[0].kind, NodeKind::Mesh(_)));
assert!(a[0].extras().mask.is_some());
let NodeKind::Path { path, .. } = &a[1].kind else { panic!("editable path") };
assert_eq!(path.anchor_count(), 2);
assert_ne!(path.subpaths[0].anchors[0].h_out, path.subpaths[0].anchors[0].p);
assert_ne!(a[0].id, a[1].id);
// The normal EPS path remains available explicitly.
let page = crate::import_with(file.as_bytes(), false).unwrap();
assert!(matches!(page.document.layers[0].children().unwrap()[0].kind, NodeKind::Image(_)));
// A wrong editing copy must still fail the page comparison.
let wrong = file.replace("%%BoundingBox: 0 0 100 100", "%%BoundingBox: 0 0 120 100");
let r = import(wrong.as_bytes()).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("different page")), "{:?}", r.warnings);
}
+3
View File
@@ -552,8 +552,11 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
// Untrusted: capped, named and pointing at objects the file has.
doc.tidy_saved_selections();
doc.tidy_graph_designs();
doc.raster_effects.add_around = doc.raster_effects.add_around.clamp(0.0, vectorcraft_doc::rastersettings::MAX_ADD_AROUND);
let mut depth_notes = vec![];
doc.limit_depth_noting(&mut depth_notes);
// Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite).
doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect();
Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles })
}
+1 -1
View File
@@ -225,7 +225,7 @@ fn approx_equal_documents_compare_equal() {
fn bug_f64_bit_exact_roundtrip() {
check_native_roundtrip_exact(&rich_doc()).unwrap();
let mut d = Document::new(126.86291501015239, 214.69463130731182);
d.raster_effects_ppi = 0.1 + 0.2;
d.raster_effects_ppi = (0.1 + 0.2) * 1000.0;
check_native_roundtrip_exact(&d).unwrap();
}
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff. Show more