Compare commits

...
Author SHA1 Message Date
bflatastic bb14031aa3 Canvas: selected point type's baseline marker runs under each line, centred and right-aligned type too (#2879)
The marker ran from the anchor to the right edge of the bounds, so centred type showed half a line and right-aligned type none, and rotated type a level line. It now draws each line's baseline in text space (TextObject::baselines, the lines a click selects the type on), turned with the type.
2026-10-18 19:02:52 +07:00
bflatastic eec304dde1 SVG export: a Japanese face's numbered weight (W0–W9) is its font-weight, W6 600 (#2873)
style_weight reads W0–W9 style names (Hiragino, Morisawa…) as their weight classes before the English words, so SVG and CSS export write font-weight 600 for W6, and nearest-style matching weighs them too.
2026-10-18 19:02:52 +07:00
bflatastic b934451b6d Docs: the Mirror Repeat axis rows name #2877 and its passing CI, and keep #2564 in the status lines 2026-10-18 19:02:44 +07:00
AJ Richardson 411f5f7680 prioritize bounding-box handles over mirror axis 2026-10-18 19:02:44 +07:00
AJ Richardson 63cebd392a Add interactive Mirror Repeat axis controls 2026-10-18 19:02:44 +07:00
bflatastic 5906f53ca5 Fonts: the timed look for activated fonts doesn't wake an idle app; the next frame reads it (#2884) 2026-10-18 19:02:44 +07:00
bflatastic 3041b6a672 Tests: the font-service checks of #579 and #2576 are one test, since the platform lister is set once per process (#2884) 2026-10-18 18:56:38 +07:00
nrq6993-a11yandCursor 640c87b537 Fix #2576: poll for fonts font managers activate while the app is in front
Font services (NexusFont, Adobe Fonts) register faces with the OS without
touching font-folder mtimes; only re-asking the platform lister sees them.
Check every two seconds as well as on window focus, and test that path churn
is detected on the system font scan.

Co-authored-by: Cursor <[email protected]>
2026-10-18 18:46:03 +07:00
ham9zah bf2379f401 Part of #394: required shaping is not an alternate glyph
Highlight Alternate Glyphs (8689863f) flagged any glyph that isn't its
character's cmap glyph, so the joining forms Arabic and other complex
scripts always take (init, medi, fina, isol, rlig, ccmp and the Indic
required set) covered ordinary text in pink. A glyph is now an
alternate only when required shaping alone doesn't give it: the text is
laid out again with every optional feature off (liga, clig, calt and
the features its characters turn on) and each candidate glyph compared
by cluster. The pink boxes are measured once per text geometry, only
when the preference shows them.
2026-10-18 18:46:02 +07:00
ham9zah 561db27fef G1: test that a group of clipped images shows only its masks and their bounds
A group of four clip groups, each a placed image larger than its
rounded-square mask: one Selection click selects the group, which
shows the four masks' outlines and no image rectangle, with a bounding
box and bounds equal to the union of the masks. The behaviour landed
in #2650 (part of #2422); this covers the grouped case.
2026-10-18 18:46:01 +07:00
Tomasz LewandowskiandClaude Opus 5.5 cc1a496947 i18n(pl): Artboard is "obszar kompozycji", Guides "linie pomocnicze"
Three new Polish rows broke the catalog's own vocabulary:

- "By Artboard or Item" and "Artboard or Item Name" (Export for Screens)
  read "obszar roboczy", which this catalog uses for Workspace; Artboard is
  "obszar kompozycji" everywhere else (72 rows).
- "Enable Guides for 9-Slice Scaling" read "prowadnice"; Guides are
  "linie pomocnicze" in the other 29 rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33
2026-10-18 18:46:00 +07:00
BFlat 48ef70e873 Merge pull request #2045 from devius/ka-language
i18n: Georgian (ka) interface language
2026-10-18 18:41:19 +07:00
BFlat b58100c2f2 Merge pull request #2859 from ham9zah/fix/ai-rtl-paragraph-alignment
G9: .ai right-to-left paragraphs keep their alignment
2026-10-18 18:41:03 +07:00
bflatastic 37b90eb050 i18n: Georgian rows for the strings added since #2045 last merged main
Cancel Export, Clear History, Justification…, the .ait template and editing-data notes, the variable-library note, and the style undo templates.
2026-10-18 18:29:55 +07:00
Davit Matchakhelidze 7cf8dd4764 i18n: Georgian (ka) interface language
Squashed from #2045 (merge commits from main dropped).
2026-10-18 18:09:14 +07:00
ham9zah 4547439176 G9: .ai right-to-left paragraphs keep their alignment
A right-to-left paragraph in an Illustrator editing copy (key 33 of its
paragraph attributes) stores its alignment relative to its direction:
"left" lays the line out ending at the anchor and "right" starting there.
The importer read the alignment alone, so RTL point type opened shifted by
its own width (Arabic headings past the artboard edge, subtitles starting
where their title ends, a label over a QR code).

Checked locally against ~1,440 single-line point-type stories in a user's
own files (never committed): RTL + left ended at the anchor in 332 of 336,
RTL + right started there in 51 of 51; LTR and centred type unchanged.
Justified alignments keep their current mapping (too few samples).
2026-10-18 18:08:58 +07:00
BFlat 797e899c56 Merge pull request #2865 from storytold/fix/batch10-followups
Batch follow-ups: #2842 note translated, test fixes for #2824, #2771 and #2829, ROADMAP
2026-10-18 18:08:29 +07:00
bflatastic 6516e8611c Tests: freeform points recolored from CMYK stops mix in CMYK, as #2829 makes them (#2812) 2026-10-18 13:26:54 +07:00
bflatastic e342c2843a Tests: the bidi caret test's glyphs carry Glyph Scaling's scale of 1 (#2771) 2026-10-18 13:26:54 +07:00
bflatastic 52b6543c2e Docs: the ROADMAP's 2026-10-18 row includes the second batch 2026-10-18 13:26:54 +07:00
bflatastic 1e3ab306d4 Tests: the dropdown keyboard test clears the texture updates it doesn't apply (#2824) 2026-10-18 13:26:54 +07:00
bflatastic 1b4c421bc4 .ai import: the variable-library note reads its counts after a colon and is translated in every complete language (#2842) 2026-10-18 13:26:54 +07:00
BFlat 73f40e3fd3 Merge pull request #2840 from KaboomFox/plugin-dialog-scrolls
Dialogs: a plug-in's parameters scroll when they don't fit the window
2026-10-18 13:26:39 +07:00
BFlat 049739b85e Merge pull request #2824 from MarekSpodBiedry/issue-2789
Close a dropdown list after an option is picked with the keyboard (#2789)
2026-10-18 13:26:24 +07:00
BFlat dcad0878c5 Merge pull request #2802 from fabian-co/perf/cmyk-gradient-mesh
Render: gradient meshes in CMYK documents draw several times faster
2026-10-18 13:26:12 +07:00
BFlat 08c21446b4 Merge pull request #2564 from fabian-co/feat/export-progress-dialog
Export progress dialog with Cancel, on the shared cancel token
2026-10-18 13:25:58 +07:00
BFlat d4a68dd73f Merge pull request #2771 from VL420/feat/justification
G4: Paragraph panel menu › Justification… (word and letter spacing, glyph scaling, auto leading, single word justification)
2026-10-18 13:25:41 +07:00
BFlat 45ca05e7b2 Merge pull request #2852 from dotCooCoo/chore/parity-doc-duplicate-lines
Docs: target-app-parity.md lists seven Evidence items once each
2026-10-18 13:25:26 +07:00
BFlat 14d1667a7e Merge pull request #2851 from dotCooCoo/fix/live-commands-refuse-text-numbers
Blends, meshes and envelopes: a number given as text is refused
2026-10-18 13:25:16 +07:00
BFlat 5a2510576f Merge pull request #2846 from dotCooCoo/fix/slice-drags-snap
Slices: resizing a slice snaps as drawing one does
2026-10-18 13:25:00 +07:00
BFlat 47b5e886f8 Merge pull request #2845 from dotCooCoo/fix/shift-move-snaps-along-its-line
Selection: a Shift-constrained move that snaps stays on its line
2026-10-18 13:24:46 +07:00
BFlat 650e938b68 Merge pull request #2844 from dotCooCoo/fix/path-bounds-with-huge-control-points
Paths: a curve with a handle near f64::MAX is measured by its control points
2026-10-18 13:24:32 +07:00
BFlat 36d751b72f Merge pull request #2843 from dotCooCoo/feat/history-clear-history
History: the panel menu clears the history
2026-10-18 13:24:18 +07:00
BFlat c40718fafa Merge pull request #2842 from dotCooCoo/feat/ai-import-warns-unread-variables
.ai import: opening a file with a variable library warns that its variables weren't read
2026-10-18 13:24:02 +07:00
BFlat d9d1a1aba3 Merge pull request #2839 from dotCooCoo/fix/export-for-screens-example-name
Export for Screens: the dialog's example names the file the export writes
2026-10-18 13:23:47 +07:00
BFlat 2c4f059444 Merge pull request #2838 from dotCooCoo/fix/pdf-export-keeps-created-date
PDF export: PDF and .ai files keep the document's creation date
2026-10-18 13:23:33 +07:00
BFlat dba668240c Merge pull request #2829 from dotCooCoo/fix/freeform-gradients-keep-cmyk
Color: freeform gradients keep CMYK inks when expanded, meshed or edited
2026-10-18 13:23:18 +07:00
BFlat a0657c9a22 Merge pull request #2836 from dotCooCoo/fix/drag-budget-test-threads
Tests: the drag-budget test sets its thread count, and no render test changes the default
2026-10-18 13:23:04 +07:00
BFlat 5f20ef194b Merge pull request #2783 from dotCooCoo/fix/linux-save-picks-chosen-type
Save As: on Linux the file type picked in the save dialog decides the format
2026-10-18 13:22:50 +07:00
BFlat eb8eaa7aa0 Merge pull request #2825 from dotCooCoo/fix/pattern-rename-refuses-swatch-names
Patterns: Pattern Options refuses a name another swatch has
2026-10-18 13:22:35 +07:00
BFlat 1d6daaefcc Merge pull request #2819 from dotCooCoo/fix/template-refuses-legacy-ai
Save as Template: aiVersion 7 is an error for a .ait, and the dialog shows no Version
2026-10-18 13:22:20 +07:00
BFlat 78b7ffe1fb Merge pull request #2813 from dotCooCoo/fix/export-ait-names-save-as-template
Export: a .ait export names file.saveAsTemplate, and the CLI's convert writes the template
2026-10-18 13:22:06 +07:00
BFlat 31df7aee9a Merge pull request #2812 from dotCooCoo/fix/cmyk-document-converts-rgb-everywhere
Color: type, gradient, mesh and swatch commands store RGB input as color-managed CMYK in a CMYK document
2026-10-18 13:21:51 +07:00
BFlat 2909972140 Merge pull request #2803 from dotCooCoo/fix/history-template-step-names-translated
i18n: the History panel translates the step names that contain a style's name
2026-10-18 13:21:36 +07:00
BFlat cfe77f1706 Merge pull request #2805 from dotCooCoo/fix/marquee-intertwine-by-members
Selection: a marquee selects an Intertwine group or a repeat only where it meets their art
2026-10-18 13:21:22 +07:00
BFlat 69dad83148 Merge pull request #2782 from dotCooCoo/fix/tools-skip-shaper-sources
Shaper: the Lasso, Smart Guides and the anchor tools skip a Shaper Group's original shapes
2026-10-18 13:21:08 +07:00
BFlat 5646fa7253 Merge pull request #2796 from dotCooCoo/fix/appearance-inline-3d-and-channel-dropdowns
Appearance: the inline effect editor shows 3D, Warp, Rasterize and channel choices as dropdowns
2026-10-18 13:20:53 +07:00
BFlat 545e935e0c Merge pull request #2794 from dotCooCoo/fix/color-sliders-report-values
Accessibility: color and channel sliders report their values to screen readers
2026-10-18 13:20:39 +07:00
BFlat 46a1d9f175 Merge pull request #2785 from dotCooCoo/fix/panel-fields-shift-enter
Fields: Shift+Enter applies a pattern, layer, artboard or asset name and keeps the field active
2026-10-18 13:20:24 +07:00
BFlat f794069fbd Merge pull request #2797 from dotCooCoo/fix/whole-selected-group-anchors-drawn-selected
Direct Selection: a group selected whole shows its anchors as selected
2026-10-18 13:20:10 +07:00
BFlat c2bd5b4a52 Merge pull request #2795 from dotCooCoo/fix/caret-steps-past-zero-width-clusters
Type: Left and Right reach both sides of a character drawn with no width
2026-10-18 13:19:55 +07:00
BFlat 2623e95a13 Merge pull request #2809 from dotCooCoo/fix/repeat-tab-from-left-indent
Tabs: Repeat Tab measures the first stop from the left indent
2026-10-18 13:19:40 +07:00
BFlat ced933af47 Merge pull request #2815 from dotCooCoo/fix/rtf-rtl-justify
Place: justified RTF paragraphs marked right to left end their last line on the right
2026-10-18 13:19:24 +07:00
BFlat c29701f65b Merge pull request #2806 from dotCooCoo/fix/place-bounding-skips-hidden-art
Place: Bounding Box measures only the art that shows
2026-10-18 13:19:13 +07:00
BFlat 44e7be8b04 Merge pull request #2781 from dotCooCoo/fix/grid-images-count-decode-failures
Render: raster exports warn about an undecodable image drawn from a kept Rasterize image
2026-10-18 13:18:57 +07:00
BFlat 2c93a290e8 Merge pull request #2790 from dotCooCoo/fix/pdf-export-skips-undecodable-images
PDF export: an image that can't be decoded is left out with a note instead of failing the export
2026-10-18 13:18:43 +07:00
BFlat 646a5cc9a3 Merge pull request #2801 from dotCooCoo/fix/issue-2692
Legacy .ai export: images are written in the form the version 7 specification shows
2026-10-18 13:18:28 +07:00
BFlat 20c8296b5a Merge pull request #2807 from dotCooCoo/fix/issue-2687
Legacy .ai export: radial gradients are written with their highlight (Bh)
2026-10-18 13:18:14 +07:00
BFlat eb5bf7b828 Merge pull request #2817 from dotCooCoo/fix/legacy-ai-warns-undecodable-image
Legacy .ai export: warns when an image in art it flattens or expands can't be decoded
2026-10-18 13:18:03 +07:00
BFlat 36b85472d2 Merge pull request #2808 from dotCooCoo/fix/ai-import-covered-type-spells-text
.ai import: page type over a text object is left out only when that object's text has its letters
2026-10-18 13:17:47 +07:00
BFlat e69a08c1a6 Merge pull request #2799 from dotCooCoo/fix/issue-2034
EPS and .ai import: the note for editing data of another page size gives both sizes and the scale
2026-10-18 13:17:31 +07:00
BFlat 602b3b34bc Merge pull request #2800 from dotCooCoo/fix/ai-undecodable-editing-data-note
.ai import: a file whose editing data can't be decompressed says why its layers weren't read
2026-10-18 13:17:16 +07:00
BFlat 9361a3b3f1 Merge pull request #2810 from dotCooCoo/fix/pdf-layer-names-pdfdocencoding
PDF import: PDF and .ai layer names without a byte order mark are read as PDFDocEncoding
2026-10-18 13:17:02 +07:00
BFlat dfb1a86a23 Merge pull request #2816 from dotCooCoo/fix/xmp-nesting-limit
PDF import: an XMP packet nested more than 256 deep is skipped instead of overflowing the stack
2026-10-18 13:16:47 +07:00
BFlat 3f673c35eb Merge pull request #2776 from dotCooCoo/fix/mcp-canned-replies-respect-queue-limit
MCP: parse errors and overlong lines wait for room in the 64-line queue
2026-10-18 13:16:30 +07:00
BFlat 2d2698e5fb Merge pull request #2837 from dotCooCoo/fix/svg-import-paths-on-windows
SVG import: hrefs that need no folder stay as written, and images in a non-absolute folder are read
2026-10-18 13:16:15 +07:00
Louise FoxandClaude Opus 5.5 0842941f8d Dialogs: a plug-in's parameters scroll when they don't fit the window
A plug-in's dialog (Effect › Plug-ins, Object › Plug-ins) lays out each
parameter of its manifest as a row, and a manifest may declare up to 64.
With many of them the dialog ran off the top and the bottom of the
window, its heading and its OK and Cancel buttons off screen. A plug-in
of 64 number parameters got a dialog 2,329 px tall, its top at y = -755
in a 900 px window.

modal::scrolled_rows() puts rows in a vertical scroll area as tall as
the screen less the rest of the modal window and its edge gaps.
modal::show_at() measures that rest once it has drawn the window, for
the next frame, and asks for that frame at once if the rest changed.
The dialog then fits without waiting for input. schema_fields() puts the
parameter grid in it, for filters and effects alike. A dialog of a few
parameters is as tall as before.

a_plug_in_dialog_of_many_settings_fits_the_window opens an effect's and
a filter's dialog of 64 parameters in a 1280 × 900 window, and one of 4.
Before the change it failed with "effect: the dialog is at
[[457.0 -755.0] - [823.0 1574.0]] in the [1280.0 900.0] window".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-18 12:41:25 +07:00
MarekSpodBiedry df1bf3fe32 Close a dropdown list after an option is picked with the keyboard (#2789) 2026-10-18 12:41:25 +07:00
Louise FoxandClaude Opus 5.5 dbb6b470b3 Menus: leave installed plug-ins' names out of the strings to translate
menu_strings() lists the strings a complete language catalog has to
cover, and it leaves out names that are user data: fonts, recent files,
saved views and libraries. An installed plug-in's name stayed in.
Plug-ins are installed for the whole process, and a test that installs
one before complete_languages_translate_every_menu_string runs made that
test fail with the plug-in's name as an untranslated menu string.

lists_plugin() tells an Object › Plug-ins filter or an Effect ›
Plug-ins effect by its command and params. Those are part of the menu
item itself and stay right while another test installs or removes
plug-ins. menu_strings() uses it, and so does
menu_catalogs_translate_every_menu_label in place of its own copy of the
rule.

plugins_are_listed_in_the_object_and_effect_menus checks it. Before the
change it failed with "Desaturate… is a menu string to translate".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-18 12:41:25 +07:00
Fabian GrajalesandClaude Opus 5.5 9735518c84 Render: a gradient mesh is rasterized into at most 4 MP (16 MB) at once
The image a mesh's quads are filled into is clipped to the frame; at 16 MP a
mesh filling a large canvas allocated up to 64 MB per frame. A larger mesh is
drawn quad by quad, backed by its patches' outlines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dt49z1ubK9x1bPq51APgz3
2026-10-18 12:41:21 +07:00
Fabian GrajalesandClaude Opus 5.5 cf5caa43d5 Render: gradient meshes laid edge to edge meet without seams
Drawing each mesh as one image through its antialiased outline (dadfa06b)
left a thin line of the background wherever two meshes met: the two
partly covered edge pixels don't add up to full coverage. A page made of
thousands of small meshes (a PDF from a gradient-mesh illustration) showed
a grid of lines on screen and in raster exports.

- The outline is flattened and moved out by half a device pixel
  (grown_outline), as the quads drawn one by one are, and filled once: no
  doubled edge, so translucent meshes are grown too.
- The edge colours spread two pixels out of the quads (the grown edge
  samples up to about a pixel and a half out), and the buffer has two
  pixels of margin.

Tests: an_outline_grows_by_half_a_pixel_both_ways_round; the edge-spread
test checks the second pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:41:21 +07:00
Fabian GrajalesandClaude Opus 5.5 ed69938e0e Render: gradient meshes in CMYK documents draw several times faster
A PDF of thousands of gradient meshes in a CMYK document with transparency
took minutes to export (186 s at 72 ppi for an 18,577-mesh page). Each mesh
was cut into quads coloured in RGB, and drawing a CMYK document by ink planes
separated every quad's colour back to CMYK through the profile, twice: the
4,096-colour separation cache, flooded by the meshes' interpolated colours,
cleared over and over.

- doc: GradientMesh::color_at mixes corners that are all CMYK (or all grey)
  in that space, as a PDF shading mixes in its own: exact inks, nothing to
  separate.
- render: the separation cache holds 65,536 colours.
- render: a mesh's quads are filled into one image by scanline (every pixel
  to the quad its centre is in: no seams) and drawn through the mesh's
  outline, instead of one path per quad; meshes over 16 MP on screen are
  drawn quad by quad as before, backed by each opaque patch's outline (#2456).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:41:21 +07:00
Fabian GrajalesandClaude Opus 5.5 ec410552cf Export: a progress dialog with Cancel for background exports
While an export runs in the background (Background Export), a dialog shows
what is being exported and how far it has got: artboard by artboard for a
raster export of several artboards, busy otherwise. Cancel (also Esc) stops
it and writes no file; Continue in Background leaves it to the status bar.
It opens after 0.4 s and never over another dialog.

Built on the shared vectorcraft_doc::cancel (#2546): fileio::progress::Progress
wraps its CancelToken and a progress sink, so a cancelled export stops
drawing and keeps no half-drawn pattern tile or effect raster.

- ui: background jobs carry their Progress; export_progress draws the
  dialog; UI commands file.cancelExport and file.exportInBackground.
- i18n: the strings in every language with complete menus, Danish, Dutch
  and Indonesian included; "{n} more exports waiting" is a plural (trn).
- docs: ui-parity, gaps (G12) and ROADMAP rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dt49z1ubK9x1bPq51APgz3
2026-10-18 12:40:20 +07:00
VictorandClaude Opus 5.5 a275496ef4 G4: Paragraph panel menu › Justification… (word and letter spacing, glyph scaling, auto leading, single word justification)
The Paragraph panel menu's Justification… was a disabled stub, and justified
lines stretched word spaces between fixed limits (80% to 150%).

Each paragraph now has Justification options: Word Spacing, Letter Spacing
and Glyph Scaling (Minimum, Desired, Maximum, in percent), Auto Leading and
Single Word Justification. Desired applies to every line; a justified line
fills its measure with word spacing first, then letter spacing, then glyph
scaling, each within its range, and past them its word spaces take the
rest. The Every-line Composer scores lines by that flexibility. Auto
Leading sets the leading of characters whose leading is Auto. A justified
line holding one word is spread between its letters (Full Justify) or
aligned left, centre or right.

The defaults are the documented ones (80/100/133, 0/0/0, 100/100/100, 120,
Full Justify), saved only when they differ. Because the default maximum
word spacing is 133%, justified paragraphs break differently than before;
the Every-line Composer's second pass is now unbounded for justified text
(it used to give up past four times the stretch and fall back to the
Single-line Composer, which the narrower default range would make common),
and lines that can't stretch at all are still refused.

text.setFormat takes wordSpacing, letterSpacing, glyphScaling ([min,
desired, max] or {min?, desired?, max?}), autoLeading and
singleWordJustification; Minimum ≤ Desired ≤ Maximum must hold or nothing
changes. Paragraph styles carry the options (New and Redefine capture
them), and Reset Panel resets them. Justification… opens a dialog
(ui.justification) with Preview; OK is disabled while a row is out of
order. Live SVG text places characters the options move, and a glyph that
Glyph Scaling scales is stretched to its width; PDF uses the glyphs'
transforms. Vertical type isn't glyph scaled; type on a path doesn't use
the options.

Docs: docs/mcp.md, docs/type-parity.md, docs/gaps.md.

Closes #2755.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:40:14 +07:00
dotCooCoo 5851d8b1ad Docs: target-app-parity.md lists seven Evidence items once each
The Evidence list in docs/target-app-parity.md had two or three
versions of several items.

Seven items now have one line each: the Actions panel, Editing type,
Releasing ruler guides, the Layers panel, choosing the Artboard tool,
Image Trace Flat Logo mode and Create Strokes. Each kept line holds
every number, path and pull request reference of the lines it
replaces. The Layers panel line gains the #2260 note on layers dropped
under the last row. The Create Strokes line keeps the newer of its two
statements, which no longer lists the skeleton graph among the
remaining work.

Open pull requests edit the lines of seven other items, or the lines
beside them (Preferences that act, Initial live Revolve, Live effects
with previewing dialogs, Type, Masks and clipping, SVG/SVGZ, and EPS
and DXF), and those items are unchanged.
2026-10-18 12:40:13 +07:00
dotCooCoo d8b0ce1013 Blends, meshes and envelopes: a number given as text is refused
The blend, envelope and gradient mesh commands read their optional
numbers with f64_or or as_f64, which fall back to a default for a value
that is not a JSON number. object.blend.make {"steps": "8"} made a
Smooth Color blend and reported success. Blend Options did the same
with steps, distance and value. Make with Warp, Reset with Warp and
Envelope Options did the same with bend, h and v (and their long
names). Make with Mesh, Reset with Mesh and Create Gradient Mesh did
the same with rows, cols and columns. Envelope Options did the same
with fidelity, Create Gradient Mesh did the same with highlight, and
Set Mesh Point Color did the same with opacity.

These commands now fail with "`<key>` must be a number, not <value>"
for a value that is not a number or null, as the transform commands
do, and change nothing. Envelope Options refuses the warp keys whether
or not a warp envelope is selected. Create Gradient Mesh refuses an
`at` that is not exactly two numbers with "`at` must be [x, y]".
Before, it took the first two entries of a longer array, and for any
other value it made a mesh with no lines through the point.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:40:12 +07:00
dotCooCoo 282ed8955b Slices: resizing a slice snaps as drawing one does
Dragging a user slice's edge or corner handle with the Slice Selection
tool never snapped, while drawing a slice with the Slice tool snaps to
the grid, to whole pixels, to the art and to the ruler guides.

The handle drag now gathers its targets when the handle is pressed and
snaps what the handle moves through PointSnap::snap_scale, as the
Selection tool's bounding-box handles do: to the nearest gridline with
Snap to Grid, to whole pixels with Snap to Pixel, to the art and the
artboards with Smart Guides, and with Smart Guides off to the art's
anchors and the ruler guides with Snap to Point. Then a shown, unlocked
ruler guide within 5 px moves the side or corner onto it, as the Slice
tool moves a drawn slice's corner onto a guide. Shift still keeps the
proportions, and Alt still resizes about the center.

Docs: docs/target-app-parity.md.
2026-10-18 12:40:11 +07:00
dotCooCoo 034ef916ad Selection: a Shift-constrained move that snaps stays on its line
A move that Shift kept level, upright or on a diagonal left that line
when it snapped. With View › Snap to Grid on, a level Shift-drag also
moved the selection up or down onto a gridline, and Snap to Pixel,
Smart Guides and Snap to Point moved it off the line in the same way.
Each axis snapped on its own after Shift had constrained the move.

With the Selection tool, and with Group Selection and Direct Selection
moving whole objects, a Shift-constrained move now slides along its
line until it snaps. With Snap to Grid or Snap to Pixel, the move
slides along the line by the shorter of the two distances that put it
on a gridline or a whole pixel on either axis. The bounds line up with
the other objects, or space evenly with them, only on the axes the line
runs along, and the grabbed point stops where the line passes an anchor
or crosses a ruler guide. A level or upright move snaps along its own
axis only. Moves without Shift snap as before.

Docs: docs/target-app-parity.md.
2026-10-18 12:40:10 +07:00
dotCooCoo 138e9ca466 Paths: a curve with a handle near f64::MAX is measured by its control points
PathData::bounds measured each segment by its start, end and extrema,
as kurbo's bounding_box does. For a curve with a handle at about 1e308
in size, or an infinite one, kurbo's extrema computation overflows and
drops the extreme. The box then held only the anchors. The canvas check
after an edit reads that box for the selected objects, so path.create
kept a curve with handles at 1e308.

The new segment_bounds measures a segment by its control points when
one of them has an x or y that is infinite or larger in size than
f64::MAX / 16, and by its extrema otherwise, bit for bit as before.
curve_bounds joins those boxes as kurbo does, and PathData::bounds uses
it, so such a curve's box holds the whole curve and the canvas check
refuses an edit that leaves one selected.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:39:09 +07:00
dotCooCoo 1caa957531 History: the panel menu clears the history
The History panel menu had no Clear History, and VectorCraft had no
command that removes the undo and redo steps. In the reference app,
Clear History in the History panel menu removes the states after the
selected state, and it removes the states before it while their
changes remain in the document.

edit.clearHistory (Clear History) removes every step to undo and to
redo in the active document and returns how many it removed. The
document and the selection stay as they are, and clearing is not an
undo step. Typing or a drag still in progress becomes a step first, as
Undo makes it one, so its change stays in the document. The command is
disabled when there is no step to clear, and it waits while an input
method composes, as Undo does.

The History panel menu has Clear History after Step Forward. It runs
the command and is disabled when there is no step to clear.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:39:07 +07:00
dotCooCoo 4803573940 .ai import: opening a file with a variable library warns that its variables weren't read
A .ai can keep its variables and data sets in a variable library in
its editing data. VectorCraft doesn't read that library, and such a
file opened with an empty Variables panel and no warning about it.

The reader now counts the library's variables and data sets in the
editing data's /Document dictionary, and opening the file adds a
warning: "the file's variables and data sets weren't read (2
variables, 3 data sets): the Variables panel doesn't list them". The
same warning appears when the layers can't be read and the file opens
from its PDF part. A PDF that opens from the editing data it carries
gets the warning too. A library with no variables and no data sets
gives no warning.

Docs: docs/target-app-parity.md.
2026-10-18 12:38:06 +07:00
dotCooCoo 3c0c112fbd Export for Screens: the dialog's example names the file the export writes
The Export for Screens dialog built its Example line from its fields as
typed, so it often showed a name the export does not write. It used the
artboard's name with its spaces (Artboard 1.png where the export writes
Artboard-1.png) and the first artboard whatever the range, kept a
prefix, a suffix or a device name such as CON as typed, used the scale
text as the row folder instead of the row's own folder or its size,
kept a vector row's @2x suffix, left out the folder that Create
Sub-folders adds, and read a counter set as text as 1.

The line now comes from the export's own naming. The naming part of
document.exportForScreens moves into a Naming type that the export
uses for every file, and fileio::screen_first_file returns the path of
the first file the export writes for the given params, without encoding
or writing anything. The dialog passes it the params it exports with,
without the destination, so the line shows before a folder is chosen
and is left out while the settings choose nothing to export. The Start
at field shows the counter the export uses when it was set as text.

Docs: docs/target-app-parity.md.
2026-10-18 12:37:29 +07:00
dotCooCoo aa9cf83fd2 PDF export: PDF and .ai files keep the document's creation date
The engine built the PDF options without a creation date, so the
writer dated every PDF and PDF-compatible .ai file with the time it
was written. A document created on 2020-01-01 (File › File Info
shows the date) exported as a PDF with today's date in /CreationDate
and xmp:CreateDate. SVG export with the metadata option already
writes the document's own date.

The options now carry the document's File Info created date and the
time of writing as the modification date. A document without a
creation date is dated with the time of writing, as before, so PDF/X
files still get their required date. The PDF writer writes one date
for both, so the export then sets /ModDate, xmp:ModifyDate and the
PDF/A history dates to the time of writing; /CreationDate and
xmp:CreateDate keep the document's date. The page information mark
and the embedded editing data's modification date keep the time of
writing.

Docs: docs/mcp.md (File Info), docs/target-app-parity.md.
2026-10-18 12:36:27 +07:00
dotCooCoo 24d4777d1a Color: freeform gradients keep CMYK inks when expanded, meshed or edited
Object > Expand and Object > Create Gradient Mesh mixed a freeform
gradient's points in display RGB and turned the result back into the
points' model with the profile-free formula. CMYK inks changed: points
of C50 M20 Y0 K10 gave mesh points of C45 M24 Y0 K22. RGB points were
stored as display sRGB, so with a working RGB space other than sRGB
the mesh showed other colors. Points colored along CMYK stops, a point
paint.freeform.splitLine added between CMYK points and a point
paint.freeform.addPoint added without a color came out as RGB.

Points that share a color model now mix in it: the C, M, Y and K
values mix as inks, and Gray, RGB and Lab components mix as they are.
Points in different models still mix in display RGB. Object > Expand
and Object > Create Gradient Mesh store the result in the working RGB
space, and in a CMYK document they store it as CMYK through the color
settings, as paint.setFill stores an RGB color. Freeform::recolor and
split_line mix with the new Color::lerp_keep_model, which
vectorcraft_doc::blend::lerp_color now calls.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:36:25 +07:00
dotCooCoo 739b0b7335 Tests: the drag-budget test sets its thread count, and no render test changes the default
The FreeBSD job fails
fx::tests::a_drag_renders_raster_effects_within_the_drag_budget at
fx.rs:2822 with 1 previewed instead of 2.

pixels_do_not_depend_on_the_thread_count called set_default_threads
with 0, 1, 4 and 8. The override is process-wide, so a Renderer::new()
in a test running at the same time took that count. With 0 threads a
drag renders the drop shadow at full resolution, as
RenderOptions::dragging documents, so stats.previewed counts only the
filtered content (1 instead of 2).

The thread-count test now sets each renderer's threads. The drag test
gives its renderers 3 threads, so it also passes where default_threads()
is 0 (one CPU, or VECTORCRAFT_RENDER_THREADS=0).
2026-10-18 12:36:24 +07:00
dotCooCoo 468d7b21e9 Save As: on Linux the file type picked in the save dialog decides the format
On Linux the save dialog is the XDG desktop portal's, shown through
rfd. rfd sends no `current_filter` and reads only the file name from
the portal's answer, and the GTK file chooser keeps the typed name
when another file type is picked. With "PDF-compatible .ai" picked,
`poster` typed for a new document was saved as a native
`poster.vectorcraft`, and so was the suggested
`Untitled-1.vectorcraft` left as it was.

When a save offers two or more file types (Save As, Save a Copy, Save
as Template and the first Save of a new document), the app now calls
the portal's SaveFile itself over D-Bus. The dialog starts on the
suggested file's type, and its answer names the type picked. A name
typed without an extension, or the suggested name left as it was,
gets that type's extension. A name typed with another save format's
extension, other than the suggested file's (for Save as Template, a
template format's), is saved in that format, and any other extension
gets the picked type's added. The dialog keeps the window as its
parent on X11 and Wayland, and rfd shows the dialog when the portal
can't. The same code runs on the BSDs; Windows, macOS and the web
build keep their dialogs.

Docs: docs/target-app-parity.md.
2026-10-18 12:36:23 +07:00
dotCooCoo db3092ae2e Patterns: Pattern Options refuses a name another swatch has
pattern.options refused a new name that another pattern had, but not
one that a color swatch, a gradient swatch, a second swatch of the
pattern or a color group had. The rename then gave the pattern's swatch
that name, so two swatches, or a swatch and a color group, had one
name. The rename also gave the new name to every swatch that had the
old one, a color swatch included, and art linked to that color swatch
kept a link to a name no swatch had. Make and Save a Copy numbered a
pattern's or a swatch's name, but not a color group's.

pattern.options now refuses a name that a swatch or a color group has,
with "a swatch or color group named `<name>` exists". It takes a name
that only the pattern's own swatches have, such as a name Swatch
Options gave the pattern's swatch, when none of them still has the old
name. A rename gives the new name only to the pattern's swatches that
had the old one. Make and Save a Copy number a color group's name as
they number a swatch's. The new message has a row in the 17 catalogs
that translate every message (COMPLETE_MESSAGES) and in bg, da, id and
nl.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:36:22 +07:00
dotCooCoo 0cf12a1611 Save as Template: aiVersion 7 is an error for a .ait, and the dialog shows no Version
file.saveAsTemplate with format ait and aiVersion: "7" wrote the
legacy PostScript .ai of version 7 under the .ait name: for a
one-artboard test document, a 7,556-byte file starting %!PS-Adobe-3.0.
The web's Save as Template dialog showed the .ai Version menu for
.ait, whose Legacy (version 7) choice set aiVersion 7.

aiVersion 7 for a .ait is now an error, "aiVersion 7 writes legacy .ai
files; a .ait template is always PDF-compatible", and nothing is
written. The 17 catalogs that translate every message have a row for
it. The dialog shows no Version row for .ait, and aiVersion "pdf"
still writes the PDF-compatible template.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/file-format-parity.md
(two stale rows for .ai and .ait removed).
2026-10-18 12:35:18 +07:00
dotCooCoo d23030ce2b Export: a .ait export names file.saveAsTemplate, and the CLI's convert writes the template
document.export, document.serialize, document.exportSelection and
Export for Screens rows answered "PDF-compatible .ait template files
can be opened but not written" for a .ait, although
file.saveAsTemplate writes them. The CLI's convert and run --export
call document.export, so vectorcraft-cli convert in.pdf out.ait
failed with that error.

The error now reads "PDF-compatible .ait template files are written
by file.saveAsTemplate", with a row in the 17 catalogs that translate
status messages and in bg, da, id and nl. convert and run --export
write a .ait with file.saveAsTemplate, which writes every artboard
with the .ai options. convert passes --replace-lossy and its .ai flags
on, and refuses --artboard and --range for a .ait.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:34:41 +07:00
dotCooCoo 2643f57b1f Color: type, gradient, mesh and swatch commands store RGB input as color-managed CMYK in a CMYK document
In a CMYK document, paint.setFill stores an RGB color as CMYK through
the color settings, but the other commands that take a color stored it
as RGB: text.create, text.createInPath, text.setStyle and
text.setRangeStyle colors, a shapeBuilder.merge hex fill,
paint.editGradient stops, freeform gradient points, gradient mesh
points, the Symbol Stainer's color, and swatch.new {colors},
swatch.newGroup, swatch.editGroup and swatch.edit. The Mesh tool sent
the fill as a hex value, so a CMYK fill came in as RGB.

These commands now store such a color through paint::new_color, as
paint.setFill does, and take keepModel to keep RGB (shapeBuilder.merge
inside its fill object). A color the swatch
or gradient already has keeps its model, so stops sent back with a
moved offset keep their colors (the Gradient panel's ramp and the
Gradient tool send keepModel), and a stop that names a process
swatch takes the swatch's color as it is, in paint.setFill gradients
too. Where the app copies an existing color, it keeps the color's
model: the Color panel's sliders for gradient stops and freeform
points, a swatch put on a stop or a point (the stop popover, Alt-click
in the Swatches panel, a drop on the Gradient panel's ramp), the fill
the Mesh tool gives a new point or the Symbol Stainer applies, and the
colors the Color Guide, Color Themes and Recolor Artwork save as
swatches.

swatch.new {color, mode} converted RGB into CMYK before converting it
into mode, so the New Swatch dialog in RGB mode stored #00ff00 as
R101 G176 B62 in a CMYK document. With mode, the color is now
converted once.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:34:40 +07:00
dotCooCoo ef9e554dd2 i18n: the History panel translates the step names that contain a style's name
The History panel and Edit › Undo and Redo did not translate the steps
that apply or redefine a character or paragraph style, or redefine a
graphic style. The engine built their names with format! ("Apply Body",
"Redefine Body", "Redefine Graphic Style “S”"), and the panel looks a
name up as a whole, so no catalog row matched a name with a style's
name in it. A name cannot be matched against template rows instead: a
character style named Brush gives "Apply Brush", which is also the name
of the brush step.

The engine now names these steps with Session::edit_named, and each
such step keeps its template and the style's name
(HistoryEntry::named) through undo and redo. The History panel and
Edit › Undo and Redo show the template's row with the name in it
(panels::history::entry_name). The name stays as the document holds
it, and the Normal styles' names are shown in the interface language.
Every complete catalog has rows for Apply {name} and Redefine {name},
and Redefine Graphic Style “{name}” had one.
every_undo_label_is_translated also requires a row for each template,
and the only step name it allows to be built with format! is the
Liquify tools' "{} Tool".

Docs: docs/target-app-parity.md.
2026-10-18 12:34:39 +07:00
dotCooCoo 438d27ffbe Selection: a marquee selects an Intertwine group or a repeat only where it meets their art
A marquee measured an Intertwine group and a repeat by their bounding
box. A marquee in the gap between two members of an Intertwine group,
or between two copies of a repeat, selected the object, although a
click there misses and a plain group needs a marquee to meet one of its
members. Direct and Group Selection's marquees also selected the
Intertwine group whole beside the anchor points of its members.

The marquee now touches an Intertwine group by its members, as it
touches a plain group, and it touches a repeat by the copies the repeat
draws, as clicks hit them. Direct and Group Selection's marquees select
the anchor points of an Intertwine group's members, and Group
Selection's E marquee selects the members inside it. Blends, and
envelopes outside Edit Contents, keep their box, as clicks on them do.

Docs: docs/target-app-parity.md.
2026-10-18 12:34:38 +07:00
dotCooCoo 7d56260b47 Shaper: the Lasso, Smart Guides and the anchor tools skip a Shaper Group's original shapes
A Shaper Group keeps its original shapes as its first child, at no
opacity, and clicks do not pick them outside Construction mode. The
Lasso, the Add Anchor Point, Delete Anchor Point, Anchor Point and
Scissors tools still picked their anchor points and segments, also
where the group erased the face, and Smart Guides, Snap to Point and
the transform tools' reference point snapped to them. The Lasso and
the anchor tools also picked art outside the isolated group or object
in isolation mode.

The Lasso and the anchor tools now visit only the art a click can pick:
the isolated object in isolation mode, and a Shaper Group without its
original shapes unless they are isolated (Construction mode). Smart
Guides and Snap to Point leave the original shapes out of their
targets, and Spacing Guides leave out their bounds when a face moves.
The transform tools' reference point snaps to an original shape only
when that shape is selected, or under the pointer in Construction
mode. The Shaper Group test document moves into the shared test
helpers.

Docs: docs/shaper.md, docs/target-app-parity.md.
2026-10-18 12:34:37 +07:00
dotCooCoo 22ce516e2d Appearance: the inline effect editor shows 3D, Warp, Rasterize and channel choices as dropdowns
The Appearance panel's inline effect editor still showed eight choice
parameters as text fields holding the stored value: Revolve's edge,
Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's
Color Model, Background and Anti-aliasing, and the channel of Levels
and Curves. Their dialogs built these lists in their own bodies, so
effect::choices did not hold them. Nothing checked a channel's value:
a typo was stored, and Levels or Curves drew it as RGB.

effect::own_choices now holds these lists, each label as its dialog
passes it to its dropdown or radio buttons. The dialogs read the lists
from it (Rasterize's Anti-aliasing from the AntiAlias list it is built
from), and the inline editor shows each one as a dropdown with the
dialog's translated labels; Rasterize's Color Model reads CMYK or RGB
by the document's mode. effect.apply and effect.setParams refuse a
Levels or Curves channel other than rgb, red, green or blue (r, g, b
and any letter case are read too) with "`channel` must be one of rgb,
red, green, blue", and the document and its undo history stay as they
were.

Docs: docs/mcp.md (effect params), docs/target-app-parity.md.
2026-10-18 12:34:36 +07:00
dotCooCoo 30e54dedcf Accessibility: color and channel sliders report their values to screen readers
The color and channel sliders gave screen readers a name but no value.
Each caller maps its own range onto the thumb's position, so a screen
reader announced the Color panel's G slider, Transform Each's Scale or
the Navigator's Zoom without the setting the field beside it shows.

color_slider_valued and channel_slider_valued take an optional
SliderValue: the number and text of the field beside the slider and
the slider's range. The accessibility node gets the number, the text
and the range, which extends to a number past the slider's end.
color_slider_named and channel_slider_named keep their arguments and
report no value. Every caller outside the tests uses the new variants:
the Color panel, Swatch Options, the Color Picker, the Navigator, the
Character panel's variable font axes and the dialog sliders.
form::slider_rail_named reports the number it edits, and the harmony
wheel's Brightness slider, which has no field, reports its brightness
in percent.

Docs: docs/target-app-parity.md.
2026-10-18 12:34:35 +07:00
dotCooCoo e461b8b818 Fields: Shift+Enter applies a pattern, layer, artboard or asset name and keeps the field active
Shift+Enter (Shift+Return) in the Pattern Options panel's Name field and
in the name fields that a double-click opens in the Layers, Artboards
and Asset Export panels ended the editing, as Enter does. These fields
draw their own text box and do not use the shared field widgets that
#2431 changed.

- Each of them now calls widgets::own_field_shift_enter, built on
  #2431's shift_enter_keeps_focus and after_shift_enter. Shift+Enter
  applies the name as Enter does and the field keeps the focus. In the
  next frame the field shows the name as applied, all selected, so
  typing replaces it.
- Enter, Escape and a click elsewhere work as before.

Docs: docs/target-app-parity.md.
2026-10-18 12:34:34 +07:00
dotCooCoo 496288a023 Direct Selection: a group selected whole shows its anchors as selected
With Direct Selection, the Pen or the anchor tools, a group, compound
path or clip group selected whole drew the anchors of its paths hollow,
the look of unselected anchors, while a path selected whole drew its
anchors filled. After two Group Selection clicks selected a path and
then its group, the group's hollow anchors covered the path's filled
ones.

With these tools, the selection overlay now draws the anchors of the
paths inside a group, compound path or clip group selected whole
filled, as it draws the anchors of a path selected whole. Direction
handles still show only on a path that is selected itself, and Direct
Selection drags only those.

Docs: docs/target-app-parity.md.
2026-10-18 12:34:33 +07:00
dotCooCoo bb7d6b487d Type: Left and Right reach both sides of a character drawn with no width
In type that holds right-to-left characters, Left and Right move the
caret through the visual caret stops (`caret_horizontal`). A cluster
drawn with no advance, such as a zero width non-joiner that the font
shapes as a glyph of its own, puts two stops at one x. A step kept the
stop with the lower byte, and the next step passed over the other one,
so the arrow keys never reached it. In Persian می‌خواهم laid out on
macOS, the caret could not be put between the non-joiner and خ with
the arrow keys.

Stops at one x are now visited one at a time in byte order. Going
right, the caret takes them in increasing order on left-to-right
glyphs and in decreasing order on right-to-left glyphs; going left,
it takes them in the reverse order. The end of a soft-wrapped line,
which the caret shows on the next line, is not one of those stops.
Where no two stops share an x, a step goes where it went before.
2026-10-18 12:34:32 +07:00
dotCooCoo dd5d421a9c Tabs: Repeat Tab measures the first stop from the left indent
Repeat Tab on a paragraph's first tab stop spaced the copies by the
stop's distance from the ruler's 0. The reference app measures that
distance from the paragraph's left indent. With a left indent of 20 pt
and one stop at 50 pt, the copies were 50 pt apart instead of 30.

Repeat Tab now spaces the copies of the first stop by its distance
from the left indent of the paragraph whose stops the Tabs panel
shows: the paragraph the caret is in while the Type tool edits text,
otherwise the first paragraph of the first selected type object. A
stop left of the indent repeats at its distance from the indent: a
stop at 5 pt in a paragraph indented 20 pt repeats every 15 pt. The
reference app's help page does not cover this case, so this follows
VectorCraft's own rule. The copies of a later stop are still spaced by
its distance from the stop before it, and the limits of 20 copies and
2,000 pt stay.

Docs: docs/target-app-parity.md.
2026-10-18 12:34:31 +07:00
dotCooCoo 5df664d1a7 Place: justified RTF paragraphs marked right to left end their last line on the right
File › Place set every justified paragraph (`\qj`) of an RTF file as
Justify with last line aligned left, so a right-to-left paragraph
(`\rtlpar`) ended its last line on the left. Such a paragraph now
places as Justify with last line aligned right, in either order of the
two control words. `\qj` in a left-to-right paragraph still places as
Justify with last line aligned left, and `\ql`, `\qr`, `\qc` and `\qd`
keep their alignments in both directions.
2026-10-18 12:34:30 +07:00
dotCooCoo b27b5b80ea Place: Bounding Box measures only the art that shows
With crop "bounding", file.place measured the placed art by each
object's visual bounds, which include the hidden members of a group.
A group holding a 40 pt square and a hidden 170 x 42 pt rectangle was
measured as 251 x 123 pt: at, rect, the place cursor and
file.place.info all used that box. A group whose only part on the page
or artboard was a hidden member was also placed, and symbol instances
were measured as a 20 pt square.

The art's box now follows the rule exports use
(vectorcraft_render::encode::drawn_bounds, made public): hidden
objects, guides and template layers do not count; the reach of effects
such as a drop shadow now counts; symbol instances are measured by
their art. The same rule picks the objects on the page or artboard.
Hidden objects on the page or artboard are still placed, and stay
hidden. Replace and the width and height that file.place answers also
measure symbol instances by their art.

Docs: docs/mcp.md (Place), docs/target-app-parity.md.
2026-10-18 12:34:29 +07:00
dotCooCoo 2fc47948d5 Render: raster exports warn about an undecodable image drawn from a kept Rasterize image
When a drop shadow or an outer glow follows Effect › Rasterize, the
renderer makes the Rasterize image on the first draw and keeps it for
the shadow and the content, which draw it again. The kept image held no
count of the images that failed to decode while it was made. With a
blur or a filter after the shadow, the content's raster was made from
the kept image and recorded no failure, so a renderer that drew the
object again took the shadow and the content from its caches and
counted nothing. Pattern tiles are drawn by a renderer that keeps these
rasters across exports: a tile drawn again with the same art, for
example after Pattern Options changes the overlap, gave an export
without the "couldn't be decoded" warning although the picture lacked
the image.

The kept Rasterize image now stores how many decodes failed while it
was made, and each draw from it adds that number to the count again, as
kept effect rasters and pattern tiles do.
2026-10-18 12:34:28 +07:00
dotCooCoo df7daff3e4 PDF export: an image that can't be decoded is left out with a note instead of failing the export
When the PDF writer keeps a PNG, GIF or WebP image as it is, it accepts
the image once its header reads, and it reports pixel data that it
can't decode only when it finishes the file. An image whose pixel data
was cut short or damaged therefore failed the whole export with "PDF
writer error: Image(...)", and nothing was written. This happened in
Save as PDF, .ai saves, Save a Copy, Print, Export for Screens and
native files with PDF compatibility, and with On Copy: PDF turned on,
Copy left the PDF off the clipboard.

When the writer fails on an image's pixel data, the export now runs
again with that image left out. Its warnings hold the note that raster
exports give, once: "an image couldn't be decoded and is missing from
the picture". This note replaces the writer's "image '<key>' could not
be decoded and was skipped", which named the image by an internal key.
An export without such an image runs once, as before. A .ai file keeps
the image in its editing data and reopens with it.

Docs: docs/mcp.md (PDF options).
2026-10-18 12:34:27 +07:00
dotCooCoo 030729d6f3 Legacy .ai export: images are written in the form the version 7 specification shows
A version 7 .ai wrote each image's matrix as [a b c d tx ty] and its
samples on comment lines of 64 hex digits. The Adobe Illustrator File
Format Specification version 7.0, which the writer follows, writes the
matrix with a space inside each bracket in the XI syntax (section 8.1)
and in all five examples (section 8.4), and every example data line
holds 60 hex digits. #2692 reports that the reference app leaves out an
image inside a clipping group, and all the art after it, in the files
written from the reporter's two SVGs.

The writer now writes [ a b c d tx ty ] and data lines of 60 hex digits
(the last holds the rest), for every image it writes. VectorCraft's
reader and the file's PostScript prolog read both forms. The result was
not checked in the reference app.
2026-10-18 12:17:05 +07:00
dotCooCoo c0e6d8eb02 Legacy .ai export: radial gradients are written with their highlight (Bh)
The version 7 .ai writer wrote a radial gradient's highlight (Bh) only
when the gradient had a focal point off its center. A centered radial
gradient, such as an SVG radialGradient without fx and fy, was written
Bb, Bg, f, BB with no Bh. Every radial instance in the Adobe Illustrator File
Format Specification version 7.0, which the writer follows, has one,
0 0 0 0 Bh for a centered highlight (sections 3.5.5, 3.5.6 and 9.3).
#2687 reports that the reference app draws such a circle in its outer
color.

The writer now writes Bh in every radial instance, 0 0 0 0 Bh at the
center. A focal point past the radius, which an elliptical gradient
written circular can have, is written on the circle, so its x and y
offsets agree with the fraction 1 written after them (section 3.5.5).
Linear instances are unchanged. The result was not checked in the
reference app.
2026-10-18 12:16:41 +07:00
dotCooCoo d7e79448d7 Legacy .ai export: warns when an image in art it flattens or expands can't be decoded
Saving a version 7 .ai expands raster effects into images and flattens
images with see-through pixels, opacity or a blending mode, and both
draw the art with the renderer. An image the renderer couldn't decode
drew nothing, so it was missing from the result, and the reply carried
no note. A semi-transparent image cut short was removed without one.

The save now compares decode_failures() before and after it writes the
artboards, as Expand Appearance and the raster exports do, and adds
"an image couldn't be decoded and is missing from the picture" to its
warnings once.

Docs: docs/mcp.md (aiVersion "7").
2026-10-18 12:16:40 +07:00
dotCooCoo d66077a15f .ai import: page type over a text object is left out only when that object's text has its letters
When a .ai or EPS file opens from its editing data, the import left
out the page's type that no text object owns whenever the regions of
the text objects made from the text document covered at least half of
it, without comparing letters. The region of point type reaches its
longest line's length in characters times its size, plus its size, to
the left and right, and one and a half times its size above and below.
A 21-character line at 10 pt reaches 220 pt to each side. The page's
other type inside that box was lost.

Now the region of a made text object counts toward that half only
when its story has the Latin letters and digits of one of the type's
pieces, compared without case, and only when such stories between
them have the letters of every piece. Only those text objects count
as drawn by the page. The check that decides whether the text
document is trusted still counts every region.

Docs: docs/target-app-parity.md.
2026-10-18 12:16:39 +07:00
dotCooCoo 4eef9b7e8c EPS and .ai import: the note for editing data of another page size gives both sizes and the scale
When the editing data of an EPS or .ai file was for a page of another
size than the file's own page, the file opened as its page or its PDF
part with the reason "its editing data is for a different page". The
reason did not say which page the editing data was for, or that it was
the same page at another scale.

The reason now gives both sizes in points: "its editing data is for a
600 × 400 pt page, not the file's 612 × 792 pt page". When the two
pages are in proportion (within the 0.5 pt per side of the check), it
gives the scale of the editing data's page to the file's instead:
"its editing data is for a 100 × 100 pt page, the file's
1000 × 1000 pt page at 1:10". Every catalog that had the old reason
has rows for both new ones.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:15:39 +07:00
dotCooCoo aafe102649 .ai import: a file whose editing data can't be decompressed says why its layers weren't read
A PDF-compatible .ai, or a PDF saved with the editing data, whose
editing data can't be decompressed (damaged zlib or Zstandard data, or
data that decompresses to more than 256 MB) opened from its PDF part
with no note about its layers. layered_ai returned on the
decompression error without a note. Data that decompresses to layers
that can't be read gets one, and so does an EPS.

layered_ai now gives the same note with the decompression error as
its reason: "the file's layers weren't read from its editing data (its
zlib compression is damaged): they come from its PDF part". The note
counts as a loss, so document.export or Save As over a PDF saved with
the editing data asks first (acknowledgeLoss), as it already did for
a .ai. The three reasons have rows in the 17 catalogs that translate
every message.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 12:15:32 +07:00
dotCooCoo ffc12babf2 PDF import: PDF and .ai layer names without a byte order mark are read as PDFDocEncoding
The layers of PDF and PDF-compatible .ai files come from their
optional content groups, whose /Name is a PDF text string. The import
read a name without a byte order mark as Latin-1. Such a string is
PDFDocEncoding (ISO 32000-1 Annex D, Table D.2), which differs from
Latin-1 at 0x18 to 0x1F and 0x80 to 0xA0. A group named
(Art\205final) opened as a layer named "Art", U+0085, "final" instead
of "Art–final", and curly double quotes (0x8D, 0x8E) and the euro sign
(0xA0) became control characters and a no-break space.

text_string now reads such a string with the PDFDocEncoding table that
File Info already used for the document information dictionary. The
table moves from file_info.rs to import_scan.rs, and File Info reads
the information dictionary's strings with text_string.
2026-10-18 12:15:31 +07:00
dotCooCoo 830cefcaf6 PDF import: an XMP packet nested more than 256 deep is skipped instead of overflowing the stack
When a PDF, PDF-compatible .ai or .ait opens, crates/pdf/src/file_info.rs
reads its File Info from the XMP packet with roxmltree, which parses
nested elements recursively and has no limit on their depth. A packet
with elements nested 10,000 deep (70 KB) overflowed a 1 MB stack, and
one nested 100,000 deep (700 KB) overflowed an 8 MB stack. A stack
overflow aborts the app.

File Info now counts how deep the packet's elements nest before roxmltree
parses it, and leaves out a packet nested more than 256 deep, as it
leaves out a packet that is not XML. The document information
dictionary then gives the File Info.
2026-10-18 12:15:31 +07:00
dotCooCoo bdf6059155 MCP: parse errors and overlong lines wait for room in the 64-line queue
The MCP server's reader queues at most 64 lines for the engine and
stops reading past that until the engine catches up. The reply to a
line that is not JSON, or that is longer than the line limit, skipped
that check: while a call had run for less than the busy limit (two
seconds by default), or while the engine was behind, every such line
added a reply to the queue. In the new test, 192 lines that are not
JSON leave 192 replies queued on main.

These replies now wait for room as requests do, and still go out at
once when the running call has passed the busy limit.
2026-10-18 12:15:30 +07:00
dotCooCoo c73945d244 SVG import: hrefs that need no folder stay as written, and images in a non-absolute folder are read
SVG import checks each linked image's path with craft-docpath, and two
of its results were wrong, most visibly on Windows. A missing image's
link kept the path that craft-docpath returned, with its separators
rewritten: an href of /abs/photo.png became \abs\photo.png on Windows,
and SVG export with linked images writes that back as the href. A
folder that is not absolute, such as /tmp on Windows or a relative
folder on any system, made each relative href, and on Windows each
href rooted without a drive, resolve to a path that is not absolute,
so those images were not read. Two vectorcraft-svg tests failed on
Windows because of this.

A link now keeps the href as written (decoded) when the href resolves
to the same path without the SVG's folder, and keeps the path found
when it needs the folder: a relative path, or on Windows a path rooted
without a drive. A file: URL keeps the path it names. The folder is
made absolute against the working directory when it is not, as the
engine's reader does with the document's path. Hrefs that were refused
are still refused: paths on other computers, devices and URLs are not
read.
2026-10-18 12:15:29 +07:00
Brandon ThomasandClaude Opus 5.5 904f0fe522 Release 0.13.1: retry stapler validate on Apple network errors
0.13.0's macOS release job failed twice at `stapler validate` right after a successful notarization
and staple: the CloudKit ticket lookup lost its connection (NSURLErrorDomain -1005, Error 68). Retry
the validate up to four times with a growing pause; a missing or bad ticket still fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 00:00:06 -04:00
Brandon ThomasandClaude Opus 5.5 5e6810655f Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 21:43:14 -04:00
skylar taylor-barrick 299a4b36ad Merge pull request #2777 from skylartaylor/open-original
Open originals and linked files only on this computer or the document's share
2026-10-17 15:45:22 -06:00
skylar taylor-barrick 6a83f6fea6 i18n: translate the new link messages into Simplified Chinese
The FreeBSD job failed complete_languages_translate_every_message: zh-hans was
missing the four Links and Attributes messages this branch adds.

The merge created a state neither side had. This branch predates main adding
zh-hans to COMPLETE_MESSAGES, so its new messages had no Simplified Chinese; the
other 17 languages conflicted and were resolved as a union, while zh-hans merged
cleanly and silently without them. da, id, nl and zh-hant are also missing them
but are not on the completeness list, so they are left as they are.

Terminology follows the file's own: 编辑原稿 for Edit Original, 浏览器 for
browser, 不是文件 after {path} 不是文件夹. These want a native speaker's eye
before release; the test only proves the strings exist.
2026-10-17 13:48:41 -06:00
BFlat e6aec0664c Merge pull request #2791 from storytold/pr/batch-1018
Docs: ROADMAP entry and parity-row cleanup for the 2026-10-18 community batch
2026-10-18 01:57:47 +07:00
bflatastic 87a5b4cc83 Docs: the ROADMAP records the community PRs landed on 2026-10-18 2026-10-18 01:57:27 +07:00
bflatastic d7a61f1c50 Docs: one M9 roadmap row and one Optical Margin Alignment row after the batch 2026-10-18 01:57:27 +07:00
BFlat 95e98d18de Merge pull request #2684 from dotCooCoo/fix/export-for-web-warns-undecodable-image
Save for Web: saving and Preview in Browser warn when an image couldn't be decoded
2026-10-18 01:57:12 +07:00
BFlat f4668bfb2b Merge pull request #2726 from dotCooCoo/fix/style-names-trimmed-on-create
Character and Paragraph Styles: a new style's name is trimmed as a rename trims it
2026-10-18 01:56:57 +07:00
BFlat 590f91574d Merge pull request #2742 from dotCooCoo/fix/tabs-panel-menu-items-enabled
Tabs: Clear All Tabs, Delete Tab and Repeat Tab can be chosen from the panel menu
2026-10-18 01:56:42 +07:00
BFlat facab88bcc Merge pull request #2746 from dotCooCoo/fix/brush-pattern-artboard-names-trimmed
Brushes, patterns and artboards: new and renamed items have their names trimmed
2026-10-18 01:56:27 +07:00
BFlat 4ffaeb1895 Merge pull request #2774 from dotCooCoo/fix/edit-undo-redo-step-names-translated
i18n: Edit › Undo and Redo name the step as the History panel does
2026-10-18 01:56:13 +07:00
BFlat 99b3917aaa Merge pull request #2715 from dotCooCoo/fix/issue-2054
Fix #2054: bounding-box resize handles snap to the grid, pixels, points and ruler guides
2026-10-18 01:56:02 +07:00
BFlat 2ef544cab7 Merge pull request #2717 from dotCooCoo/fix/linux-tablet-xwayland
Linux Display Server: XWayland when a pen is attached, where a drawing tablet's pen draws
2026-10-18 01:55:46 +07:00
BFlat 67673faea7 Merge pull request #2729 from dotCooCoo/fix/canvas-test-worker-deadlines
Tests: five tests that wait on another thread pass on a loaded machine
2026-10-18 01:55:29 +07:00
BFlat ec3a1d2a82 Merge pull request #2736 from dotCooCoo/feat/dataset-import-header-prefixes
Variables: Import Data Sets… defines the variables a file's header names
2026-10-18 01:55:15 +07:00
BFlat a96a6800c9 Merge pull request #2762 from dotCooCoo/fix/exports-keep-encoder-warnings
Export: Save for Office, CSS and Copy warn when an image couldn't be decoded or a font is missing
2026-10-18 01:55:00 +07:00
BFlat 2ca923b43a Merge pull request #2737 from dotCooCoo/fix/export-for-screens-keeps-warnings
Export for Screens: exports warn when an image couldn't be decoded or a font is missing
2026-10-18 01:54:45 +07:00
BFlat ef666b4bfd Merge pull request #2738 from dotCooCoo/fix/file-names-windows-characters
Export: names typed into file names follow Windows' naming rules
2026-10-18 01:54:31 +07:00
BFlat e26393c7d8 Merge pull request #2740 from dotCooCoo/fix/clippy-1-95-artistic-filters
Effects: vectorcraft-effects passes clippy on Rust 1.95, the minimum toolchain
2026-10-18 01:54:16 +07:00
BFlat ba69d744ae Merge pull request #2745 from dotCooCoo/fix/issue-2558-drag-preview
Part of #2558: a drag draws zoomed-in raster effects at a lower resolution until it ends
2026-10-18 01:54:01 +07:00
BFlat 879fb63248 Merge pull request #2747 from dotCooCoo/fix/batch-extension-from-format
Actions: Batch's Override Save and Export name files with the format's extension
2026-10-18 01:53:47 +07:00
BFlat 5b7411e9d1 Merge pull request #2760 from dotCooCoo/fix/ai-import-difference-note-counts-art
.ai import: the warning that the layers differ from the page gives the art's share or names the type
2026-10-18 01:53:32 +07:00
BFlat b5715f7509 Merge pull request #2761 from dotCooCoo/feat/artboards-select-all
Artboards: Ctrl/Cmd+A in the Artboards panel selects every artboard with the Artboard tool
2026-10-18 01:53:16 +07:00
BFlat 66cfc1dca6 Merge pull request #2764 from dotCooCoo/feat/history-panel-step-backward-forward
History: the panel menu has Step Backward, Step Forward and Set History Limit…
2026-10-18 01:53:00 +07:00
BFlat 74cf04fa3b Merge pull request #2767 from dotCooCoo/fix/preset-rename-writes-before-removing
PDF presets: a rename that can't be written keeps the preset under its old name
2026-10-18 01:52:45 +07:00
BFlat fda352cf7b Merge pull request #2766 from dotCooCoo/feat/place-docx
Place: Word .docx files place as area type with their formatting
2026-10-18 01:52:31 +07:00
BFlat 98fc8146dc Merge pull request #2770 from dotCooCoo/feat/tiff-ccitt-g3
TIFF: CCITT Group 3 compressed files open and place
2026-10-18 01:52:16 +07:00
BFlat 3988aafc1a Merge pull request #2772 from dotCooCoo/fix/marquee-skips-shaper-sources
Shaper: a marquee does not select a Shaper Group by the original shape of an erased face
2026-10-18 01:52:01 +07:00
BFlat 2433c44915 Merge pull request #2773 from dotCooCoo/feat/optical-margin-alignment-follows-thread
Type: Optical Margin Alignment is a setting of the whole thread
2026-10-18 01:51:45 +07:00
BFlat dfde775213 Merge pull request #2757 from dotCooCoo/fix/saved-text-sizes-in-range
Type: sizes, scales, leading, indents and spacing out of range in a file are read in range
2026-10-18 01:51:28 +07:00
BFlat 2fa0e979f3 Merge pull request #2728 from dotCooCoo/fix/saved-script-sizes-in-range
Type: superscript, subscript and small caps proportions out of range in a file are read in range
2026-10-18 01:51:15 +07:00
BFlat eba745e354 Merge pull request #2471 from VL420/feat/task-bar-more
G1: Contextual Task Bar: Join, Edit Path, Release and type conversion by selection
2026-10-18 01:50:59 +07:00
BFlat 64fda576b2 Merge pull request #2787 from storytold/pr/2706-colored-lights
3D effects: one to ten coloured lights (#2706)
2026-10-18 01:50:40 +07:00
BFlat 07bf1ddf8c Merge pull request #2395 from EricKuck/fix/ai-outer-glow-solid-silhouette
Shadows and glows: each is of the art with the ones before it in the stack, and an Outer Glow blurs a solid silhouette
2026-10-18 01:50:04 +07:00
BFlat cc5fba7577 Merge pull request #2643 from makesomethingshit/fix/2544-blend-spine-anchor-tool
Blend: edit spine points and handles with the Anchor Point tool (#2544)
2026-10-18 01:49:51 +07:00
BFlat 95427b71bf Merge pull request #2720 from MarekSpodBiedry/issue-2655
Magic Wand: only select objects inside the isolated group (#2655)
2026-10-18 01:49:34 +07:00
BFlat c9bebb92d6 Merge pull request #2721 from MarekSpodBiedry/issue-2673
Map the WMF window onto the placeable box (#2673)
2026-10-18 01:49:20 +07:00
BFlat eaa21d42d5 Merge pull request #2722 from MarekSpodBiedry/issue-2693
Write legacy .ai layer names in Windows Latin-1 and warn when a character is replaced (#2693)
2026-10-18 01:49:06 +07:00
BFlat d5cfb27989 Merge pull request #2730 from nvict-service/i18n-nl-complete
i18n(nl): complete the Dutch catalog
2026-10-18 01:48:56 +07:00
BFlat a7fded4584 Merge pull request #2732 from vangel76/fix/2491-fallback-glyph-style
Fonts: fallback glyphs keep bold/italic, Bold Italic of a missing family keeps the bold, CJK prefers an installed sans (#2491)
2026-10-18 01:48:39 +07:00
BFlat 3e09a469de Merge pull request #2733 from vangel76/fix/2521-variable-default-face
Fonts: a variable font's default face is named and weighed by its default instance (#2521)
2026-10-18 01:48:25 +07:00
BFlat 079cd4983e Merge pull request #2739 from guillehr2/fix/1876-artboard-options-xy
Fix #1876: Artboard Options has X and Y with a reference point
2026-10-18 01:48:09 +07:00
BFlat 1651e7bea7 Merge pull request #2775 from VL420/feat/roman-hanging
G4: Paragraph panel menu › Roman Hanging Punctuation
2026-10-18 01:47:57 +07:00
bflatastic e01fe134e9 Save for Web: the undecoded-image warning comes from the shared helper 2026-10-18 01:32:01 +07:00
bflatastic f5d61f6f27 Styles: a blank new style name says to give the style a name 2026-10-18 01:32:01 +07:00
bflatastic 7da58e35cc Tabs: Clear All Tabs reports a failure in the status bar 2026-10-18 01:32:01 +07:00
dotCooCoo a03583fc73 Save for Web: saving and Preview in Browser warn when an image couldn't be decoded
document.exportForWeb renders the artboard or the art and writes GIF,
JPEG, PNG-8 or PNG-24 files. When the art held an image that the
renderer could not decode, the renderer drew nothing for it, and the
files lacked the image with no message: the reply had no warnings at
all. A raster export of the same art warns that "an image couldn't be
decoded and is missing from the picture".

document.exportForWeb and document.exportForWeb.preview now compare
decode_failures before and after they render, as raster exports do,
and their replies carry that text in `warnings`. The files are still
written. Save, Download and Preview in Browser in the Save for Web
dialog show the first warning in the status bar as Save As shows a
save's notes.

Docs: docs/mcp.md (Save for Web), docs/control-protocol.md.
2026-10-18 01:32:01 +07:00
dotCooCoo 598f1de3cc Character and Paragraph Styles: a new style's name is trimmed as a rename trims it
charStyle.new and paraStyle.new kept the name they were given as it
was, while charStyle.rename and paraStyle.rename trim the white space
around the new name. So a script could make a style " Body" beside
"Body", and a blank name was refused with the message for a name that
is taken.

New Character Style and New Paragraph Style now trim the name as rename
does, compare the trimmed name with the styles there are, and refuse a
name that is then empty with rename's message. Duplicate names the copy
from the trimmed name. symbol.new and symbol.duplicate trim the name as
symbol.options trims newName, and a blank newName gives "<name> copy"
instead of a symbol named with the blank. library.use adds a library's
text style under its trimmed name, so a library file whose style is
named with spaces around it still applies.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:32:01 +07:00
dotCooCoo 3fe8aa7db9 Brushes, patterns and artboards: new and renamed items have their names trimmed
brush.new, brush.duplicate, object.pattern.make,
object.pattern.saveCopy, artboard.new and artboard.setProps kept the
name they were given as it was, and brush.options and pattern.options
kept the white space around a new name. So a script could make a brush
" Nib" beside "Nib", and a blank name made a brush, a pattern or an
artboard named with the blank. Artboard Options sends its Name field
as typed, so clearing it named the artboard "". The Brush Options
dialog, the Pattern Options panel and the Artboards panel trim the
names they send.

The eight commands now trim the name, and the brush and pattern
commands compare the trimmed name with the names there are. In
brush.new, brush.duplicate, object.pattern.make,
object.pattern.saveCopy and artboard.new, a blank name gives the name
the command gives when there is none: "New Calligraphic Brush" and the
other default brush names, "<name> copy", "New Pattern N",
"<pattern> copy" and "Artboard N". brush.options and pattern.options
keep the name when the new one is blank, and artboard.setProps gives a
blank one the artboard's default name, Artboard N for the Nth
artboard. Duplicate Brush and Save a Copy name the copy of a brush or
pattern whose saved name has white space around it from the trimmed
name (" Old " gives "Old copy").

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
bflatastic 015bab68a1 Undo/Redo: the menu names the step with the History panel's own function 2026-10-18 01:27:14 +07:00
dotCooCoo 23dd9a1f2a Fix #2054: bounding-box resize handles snap to the grid, pixels, points and ruler guides
A bounding-box handle dragged with the Selection or Free Transform tool
snapped only with Smart Guides on, and then only to the other objects
and the artboards. View > Snap to Grid, Snap to Pixel, Snap to Point
with Smart Guides off and the ruler guides did not move it, while a
move snaps to all of them.

The corner or side a handle moves now snaps as a dragged point does.
With Snap to Grid on it lines up with the nearest gridline, and with
Snap to Pixel on it lines up with whole pixels. With Smart Guides off,
Snap to Point lands it on the other objects' anchors and on ruler
guides within its distance. With Smart Guides and Snap to Point both
on, it lines up with the ruler guides as well as with the other
objects. Shift still keeps the proportions. Without Alt the opposite
side stays put, and with Alt the box still scales about its center.

Docs: docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 20590e778c Linux Display Server: XWayland when a pen is attached, where a drawing tablet's pen draws
On a Wayland session a drawing tablet's pen could not draw or click:
winit 0.30 does not bind the Wayland tablet protocol, and neither KWin
(since Plasma 6.3) nor GNOME's Mutter turns tablet input into pointer
input. Linux Display Server's X11 (XWayland) choice let the pen work as
a mouse, but it opened the window through XWayland on every start, also
without a tablet.

Linux Display Server has a third choice, X11 (XWayland) When a Pen Is
Attached (x11WithPen). It opens the window through XWayland on a
Wayland session only when /proc/bus/input/devices lists a pen as the
app starts: a device with the BTN_TOOL_PEN key and the ABS_X and ABS_Y
axes, the check PhotoCraft's tablet.rs makes. Automatic stays the
default. On Linux the Performance page shows a note for tablet pens
that don't draw.

README.md named KDE alone; the KWin commit's message says Mutter does
not emulate tablet events either, so README.md and docs/development.md
now name GNOME. The Pen pressure row of docs/hardware-parity.md said
Linux has pressure through XWayland; winit 0.30 reports none on X11
either.

Docs: README.md, docs/development.md, docs/hardware-parity.md,
docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 04e784a04f Tests: five tests that wait on another thread pass on a loaded machine
On a machine busy with other builds, five tests on main failed now and
then and passed when run alone. The canvas test for dragging an object
with raster effects gave the render worker 10 s for its frame, which
took 0.10 s alone and 5.8 to 9.4 s with four ui-egui test runs at
once. The three tests_sysclip_probe tests gave the clipboard probe's
thread 2 or 3 s. The control server's persistent-requests test sent
each request with writeln!, which sends the JSON to the socket in many
small writes, and a pause of more than the test's 120 ms line deadline
between two of them closed the connection.

The canvas test now waits up to 60 s for the worker, as another test in
canvas.rs does. The probe tests wait up to 60 s too. The control server
test sends each request line in one write and keeps its 120 ms line
deadline. Only test code changes.
2026-10-18 01:27:14 +07:00
dotCooCoo 1de20a2972 Tabs: Clear All Tabs, Delete Tab and Repeat Tab can be chosen from the panel menu
The Tabs panel menu passed false as each item's enabled argument and
its condition as the checked argument, so Clear All Tabs, Delete Tab
and Repeat Tab were always dimmed and, with a stop selected, showed a
check mark.

The three items are now enabled while the ruler shows tab stops and
show no check mark. Delete Tab and Repeat Tab act on the stop the ruler
shows selected. After Delete Tab removes the last stop, the ruler shows
the stop before it selected, and the next Delete Tab removes that one.
Clear All Tabs runs text.tabs.clear on the paragraphs the panel acts
on, so its undo step is named Clear All Tabs.

Docs: docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 35d70f5531 i18n: Edit › Undo and Redo name the step as the History panel does
Edit › Undo and Edit › Redo translated the step's name with its plain
catalog row only. Nine of the 22 catalogs have no plain row for
Feather, only the effect's menu row with an ellipsis, so in Japanese
Edit › Undo read "Featherを取り消す" after Feather while the History
panel listed the step as ぼかし.

The menu now names the step with panels::history::step_name, as the
History panel does, and keeps the name as recorded when the catalog
has no row for it. The in-window and native menus both show the new
label.

Docs: docs/target-app-parity.md.
2026-10-18 01:27:14 +07:00
dotCooCoo 2ccb147403 Variables: Import Data Sets… defines the variables a file's header names
dataset.import refused every header cell that wasn't the name of a
variable the document defines. A data source file could only fill
variables made beforehand, and `#` and `@` in a header were read as part
of a variable's name.

A header cell that names no variable now defines a new, unbound
variable in the import's undo step. `#Shown` defines a visibility
variable `Shown`, `@Photo` defines an image variable `Photo`, and a
cell without a prefix defines a text variable. A prefixed cell whose
name is already a variable of the prefix's kind names it. A cell that
is a variable's exact name keeps naming it, even when the name starts
with `#`, `@` or `%`, so files that dataset.export writes import back
as before. The import fails, naming the column, on a prefixed cell
whose name is a variable of another kind and on a `%` cell that names
no variable (graph data files). While the variables are locked, a
header that would define a variable fails the import with "variables
are locked", as New Variable does. The reply's `defined` lists the new
variables.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:27:13 +07:00
dotCooCoo a12f5b0f20 Export: Save for Office, CSS and Copy warn when an image couldn't be decoded or a font is missing
Save for Office Documents, the pictures that css.export rasterizes and
the PNG and PDF that Copy offers other apps were encoded with
fileio::encode, which drops the encoder's warnings. When the art held
an image that the renderer could not decode, the PNGs lacked it; type
in a font that is not installed came out in the fallback font in the
Office PNG and Copy's PNG and PDF. No reply or status message said so.
The app also dropped the warnings that document.exportSelection
returns.

document.exportForOffice, clipboard.exportPng and clipboard.exportPdf
now return `warnings`, and css.export adds the notes of its pictures to
its `warnings`, each once. Save for Office Documents and
document.exportSelection show the first note in the status bar in Save
As's message, and the CSS Properties panel's Export Selected CSS… and
Export All… show it in Export As's message. After Copy or Cut, once
the PNG and PDF are on the system clipboard, the status bar shows their
first note. The files are written as before.

Docs: docs/mcp.md (Clipboard, CSS Properties), docs/control-protocol.md.
2026-10-18 01:27:13 +07:00
bflatastic efb49169a3 Export for Screens: rustfmt the import list (#2737) 2026-10-18 01:27:13 +07:00
dotCooCoo 6b13dd776b Export: names typed into file names follow Windows' naming rules
Export for Screens, Batch over data sets, Save for Web and the
Libraries panel name files after text the person typed: a prefix, a
suffix, a name pattern, the name of an artboard, asset, layer, slice,
data set or library, or the document's title. The prefix, the suffix,
name patterns and Batch's data set names had only /, \ and : replaced,
so on Windows a name holding <, >, ", |, ?, * or a control character
failed to save, and Save for Web without a path named its files after
the document's title as it was. A row folder name could end in a
period, which Windows does not allow, and names such as CON, NUL, COM1
or LPT1, which Windows reserves for devices, were written as they
were.

file_part, which Export for Screens' prefix and suffix and Batch's
data set names use, now writes all of those characters as -, and name
patterns use it in place of their own copy. A new safe_file_name runs
a whole file or folder name through file_part, removes trailing
periods and spaces, and adds - after a device name that is the whole
name or comes before its first period (CON.png becomes CON-.png).
Export for Screens uses it for each file, folder and zip it names, and
keeps item names distinct after it. Save for Web uses it for slice
images and, without a path, for files named after the title. Library
files use it too. This happens on every platform. Other names are
written as before, except where a changed name now equals another
item's name, which then gets a number added.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:27:13 +07:00
dotCooCoo 26af00d108 Effects: vectorcraft-effects passes clippy on Rust 1.95, the minimum toolchain
With Rust 1.95, the workspace's rust-version, cargo clippy --workspace
--all-targets -- -D warnings stopped at vectorcraft-effects: clippy 1.95
reports nonminimal_bool on the parameter and pixel-size guards of
Cutout, Poster Edges and Neon Glow, and on the size check in
Rasterize::compose (Effect › Rasterize). Clippy 1.99 does not report
them.

Each check now has the form clippy suggests, one !(a && b && ...) in
place of !a || !(b && c) (!a || !b || !(c && d) in compose). The
operands and their order are unchanged, so the filters and compose
return early in the same cases as before.
New tests check that the three filters leave the art unchanged for a
pixel size of 0, -1, NaN or infinity and for non-finite parameters,
and that compose leaves the raster unchanged when a buffer's length
does not match its size or the image's area has no positive width or
height.
2026-10-18 01:27:13 +07:00
bflatastic 6e29afe648 Render: the drag budget for shadows returns None, as the cached-shadow path does since #2395 (#2745) 2026-10-18 01:27:13 +07:00
dotCooCoo 919970a467 Export for Screens: exports warn when an image couldn't be decoded or a font is missing
Export for Screens wrote each file with fileio::encode, which drops the
encoder's warnings, and its replies had no `warnings` key. When the art
held an image that the renderer could not decode, or type in a font
that is not installed, the files lacked the image or used the fallback
font, and neither the reply nor the status bar said so. document.export
returns the same notes in `warnings`, and Export As shows the first in
the status bar.

document.exportForScreens and assets.export now collect the warnings of
every file they export, each once, and every reply carries them in
`warnings`. The export still writes or returns every file. When there
are warnings, the Export for Screens dialog and the Asset Export panel
show the first in the status bar as Save As shows a save's notes.

Docs: docs/mcp.md (Export for Screens), docs/control-protocol.md.
2026-10-18 01:27:13 +07:00
dotCooCoo 7f858a7e59 Actions: Batch's Override Save and Export name files with the format's extension
With Override Save or Override Export on, action.batch named each
step's file in the destination folder with the step's format exactly as
written. The save and export commands read the format in any case and
with a leading dot, so a step with "format": ".svg" wrote
"Untitled_a..svg", "SVG" wrote ".SVG", "png8" wrote ".png8" and
"template" wrote ".template".

The file now ends in the format's extension: the extension as written,
in lower case and without the dot, when it is one of the format's
(".JPEG" gives ".jpeg"), and otherwise the format's first extension, the
one Export and Save use for a file name ("png8" gives ".png", "template"
gives ".vctemplate"). An unknown format ("xyz") keeps its spelling, and
the step fails with the command's error as before.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo dc48ce6c74 .ai import: the warning that the layers differ from the page gives the art's share or names the type
When a .ai or EPS opens from its editing data and its layers draw a
little differently from the file's own page, the warning gave the share
of all the pixels that differ, type included, and blamed brush strokes
and live effects. Type that the import lays out again from the file's
text, or draws in another font, raised it on its own: a line that the
page sets in pieces gave "about 1.5% of it (brush strokes, live effects
and the like ...)" with no art differing.

The comparison now also counts the pixels that differ away from type.
The warning gives that share, and when the art away from type does not
differ by more than the warning's limit, the warning names the type
instead. The warning appears in the same cases as before, and the
limits that make the file come in as its page are unchanged.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo ceddd8185f Artboards: Ctrl/Cmd+A in the Artboards panel selects every artboard with the Artboard tool
With the Artboard tool in use, Ctrl/Cmd+A after a click on a row of the
Artboards panel selected all the art. The panel now takes the keyboard
when one of its rows is clicked, as the Layers panel does for Delete,
and gives it up when the pointer is pressed outside its list or the
panel is not drawn.
While it has the keyboard and the Artboard tool is in use, Select All's
keys, and Select All in the macOS menu bar, select every artboard: the
active artboard stays active and the art's selection does not change.
Otherwise Select All still selects the art.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo aeb9314e2c Part of #2558: a drag draws zoomed-in raster effects at a lower resolution until it ends
When zoomed in, an object with a texture, a blur or a shadow was slow
to drag. Filtered content whose on-screen area passes the 8.4 million
pixel raster budget keeps no raster, and a shadow or glow larger than
4 million pixels keeps none either, so every frame of the drag ran the
effects again over up to 8.4 million pixels. A drag that scales or
rotates an object runs them again at any zoom.

The canvas now renders a tool's drag with RenderOptions::dragging. In
such a frame the renderer draws filtered content within 1,000,000
pixels, at a lower resolution where it would need more, unless it can
draw the content from an earlier frame's raster. On the multithreaded
pipeline it does the same for drop shadows, outer glows and inner
glows. No raster made that way is kept. They count as moved rasters,
so a raster made over them is made again after the drag, and the
desktop canvas renders the frame after the drag on the render worker.
That frame renders them as a still frame does. A dialog's preview,
exports and printing render as before.

zoomed_drag_perf times a drag's frames at 100% to 6400% with and
without dragging.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:35 +07:00
dotCooCoo 85fce6852e History: the panel menu has Step Backward, Step Forward and Set History Limit…
The History panel menu held only a disabled History Options… item. The
reference app's History panel menu has Step Backward and Step Forward,
which undo and redo a change, and Set History Limit, which sets the
number of History States.

The panel menu now has Step Backward and Step Forward. They run
edit.undo and edit.redo, and each is disabled when there is no step to
undo or redo, as Edit › Undo and Edit › Redo are. Set History Limit…
opens Preferences at Performance, where History States is set.

Docs: docs/target-app-parity.md.
2026-10-18 01:24:34 +07:00
dotCooCoo 6a2bb03ebf PDF presets: a rename that can't be written keeps the preset under its old name
Renaming a saved PDF preset (pdf.preset.save with newName) removed
the old file from the PDF Presets folder before it wrote the new one.
When that write failed, for example on a full disk, the folder held no
file for the preset, and the preset was gone at the next start.

The store now writes the new file first and removes the old one only
when that write succeeded. A failed write leaves the preset in the
folder under its old name. A rename that changes only the case of the
name writes the old file and then renames it, and the old file gets
its content back when the rename fails. The command still succeeds
when the folder can't be written, and the failure is logged, as
before.
2026-10-18 01:24:34 +07:00
bflatastic a209996da2 Docs: file-format-parity counts Word .docx among the formats placed (#2766) 2026-10-18 01:24:34 +07:00
dotCooCoo 1c4ead3703 Place: Word .docx files place as area type with their formatting
File > Place could not place a Word document: a .docx file was "not a
format VectorCraft reads". The reference app's File > Place reads
Microsoft Word files as text.

File > Place now reads .docx files, written from ECMA-376 Part 1
(WordprocessingML, Transitional and Strict), and sets their text as
area type the way a text or RTF file is placed. The package is read
through its relationships. The formatting comes from the document
defaults, the paragraph style (else the default paragraph style), the
character style (else the default character style), the styles each
is based on, and direct formatting. Bold, italic, caps, small caps,
strikethrough and hidden text are toggle properties: a style that
turns one on reverses the state the styles before it give.

Each run keeps its font (theme fonts from the theme part), size, bold
and italic, underline, strikethrough, all caps, small caps,
superscript or subscript (in Document Setup's proportions), raised or
lowered text and color. Each paragraph keeps its alignment, start,
end, first-line and hanging indents, space before and after, and
direction; the start indent of a right-to-left paragraph is its right
indent. Table cells are separated by tabs and each table row is a
paragraph. Text in insertions, hyperlinks, content controls and
fields' results is read. Hidden and deleted text, field instructions,
pictures, text boxes, headers, footers, notes and list numbers are
left out.

A ZIP archive named .docx, or one that holds word/document.xml, is
read as a Word document. fileio/zip.rs gains a reader for stored and
deflated files that checks each file's CRC. A package with more than
10,000 files, a part that unpacks to more than 16 MiB or nests
elements more than 256 deep, and a document with more than 4 MiB of
text or 100,000 paragraphs or runs are refused with an error. The
Place dialog lists Word files, and file.place, file.place.info and
file.place.queue report the format `docx`. .doc files are still not
read.

The Word document tests in tests_textimport.rs fail without the
change, and text_import_fuzz.rs places random and damaged packages.

Docs: docs/file-format-parity.md, docs/mcp.md,
docs/target-app-parity.md.
2026-10-18 01:21:31 +07:00
dotCooCoo dd466d79e4 TIFF: CCITT Group 3 compressed files open and place
1-bit TIFFs compressed with CCITT Group 3 did not open, place or
relink: the image decoder reads Group 4 only and refuses Compression 2
(modified Huffman) and Compression 3 (ITU-T T.4).

They are now decoded with hayro-ccitt, the CCITT decoder that EPS
import uses. Compression 2 rows start at a byte and have no EOL codes.
Compression 3 rows follow an EOL code and are one-dimensional, or, with
T4Options bit 0, one- or two-dimensional as each row's tag bit says.
Fill bits, FillOrder 2, either PhotometricInterpretation, strips and
tiles are read.

A file whose pixels would take more than the decoding allowance as
RGBA is refused as too large to draw. A strip or tile whose data ends
before its last row is refused as damaged, and so is a tile larger
than the allowance. The strips and tiles together are read from at
most as many bytes as the file holds.

Part of #1466.

Docs: docs/file-format-parity.md, docs/target-app-parity.md.
2026-10-18 01:21:30 +07:00
dotCooCoo 8fbd515d6c Shaper: a marquee does not select a Shaper Group by the original shape of an erased face
A Shaper Group keeps its original shapes as a transparent first child.
Clicks skip them outside Construction mode, but a marquee did not: one
drawn over the outline of an original shape whose face the group had
erased selected the group, where nothing is shown. Group Selection's E
marquee selected an original shape that lay inside it, and Direct
Selection's marquee and clicks selected the anchor points of the
original shapes.

The marquee now skips a Shaper Group's original shapes with the same
check clicks use, which moves into shaper_hides, and Direct Selection's
walk over the art it can pick skips them too. In Construction mode, with
the original shapes isolated, the marquee still selects them and Direct
Selection still selects their anchor points.

Docs: docs/shaper.md, docs/target-app-parity.md.
2026-10-18 01:21:28 +07:00
dotCooCoo c44fb242aa Type: Optical Margin Alignment is a setting of the whole thread
Optical Margin Alignment was stored and set per text object, so the
frames of one thread could differ. Turning it on in the first frame of
a thread left the lines in the other frames flush with their margins.

Type › Optical Margin Alignment and type.opticalMarginAlignment now set
every frame of a selected frame's thread, in one undo step, and the
reply's ids list them all. After any edit to a thread, its frames take
the setting of the frame that the edit turned on or off, else the
first frame's. Threading frames gives the thread its first frame's
setting, and frames taken out of a thread keep it. A file whose frames
of one thread differ opens with the first frame's setting in all of
them. This holds for native files and for documents restored from the
editing copy that an EPS, SVG, PDF or .ai file carries.

Threading text from an out port into existing area type
(text.thread.add with to) now flows the story into that frame at once.
Before, the story reached it only at the next edit of one of the
thread's frames.

Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
2026-10-18 01:21:27 +07:00
dotCooCoo e5945c6795 Type: sizes, scales, leading, indents and spacing out of range in a file are read in range
A .vectorcraft file's type opened with the character and paragraph
values it held. With a size of 0, the characters were not drawn. With
a size, leading, horizontal scale, left indent or space before of
1.797e308, point type's bounds were infinite or over 1e308 wide or
high, and moving or scaling it failed with "result would exceed the
canvas".

These values are now read into range when a file opens, in the type of
the layers, symbols, graph designs, patterns and the pattern definition
that Cancel puts back in pattern editing mode, and in character and
paragraph styles. A size, horizontal scale or vertical scale of 0 or
less becomes 12 pt or 100%, and a negative leading becomes Auto. A
size, leading or scale past 4,000,000 (MAX_COORD) becomes 4,000,000,
and a baseline shift, indent or space before or after past 4,000,000
either way becomes 4,000,000 or -4,000,000. A tab stop is kept to the
0 to 100,000 pt that text.tabs.set takes. A leading of 0, which legacy
.ai import stores, and the sizes, scales and indents within these
limits that SVG, PDF and .ai import keep outside the panels' ranges
open as saved.

Docs: docs/mcp.md (Paragraph attributes per paragraph).
2026-10-18 01:21:25 +07:00
dotCooCoo 81574b1648 Type: superscript, subscript and small caps proportions out of range in a file are read in range
document.setup accepts a superscript and subscript size of 1 to 500%
and a position of -500 to 500%, and a small caps size of 1 to 200%.
text.setFormat copies these proportions into the characters it
formats. A .vectorcraft file's characters, character styles and graph
label styles opened with the proportions they held. With a superscript
size of 0, the superscript characters were not drawn, and with a
superscript or small caps size of 1.797e308, moving the type failed
with "result would exceed the canvas".

These proportions are now read as the nearer end of each range when a
file opens: in the type of the layers, symbols, graph designs, patterns
and the pattern definition that Cancel puts back in pattern editing
mode, in graph label styles and in character styles. Proportions in
range and the other character attributes open as saved.

Docs: docs/mcp.md (Small Caps, Superscript and Subscript).
2026-10-18 01:21:24 +07:00
skylar taylor-barrick d44dc983e6 Merge storytold/main into open-original
Conflicts were 17 i18n .tsv files where both sides appended new strings;
resolved as a union, verified to introduce no duplicate keys.
2026-10-17 12:15:10 -06:00
VictorandClaude Opus 5.5 f7382b2844 G1: Contextual Task Bar: Join, Edit Path, Release and type conversion by selection
The task bar offers more next steps for each kind of selection:
- an open path: Join (closes it) and Edit Path (the Direct Selection
  tool); a closed path: Edit Path;
- a compound path: Edit Path and Release Compound Path;
- a clipping group: Edit Clipping Path and Release Clipping Mask
  instead of Ungroup and Isolate;
- an image selected with vector art: Make Clipping Mask instead of Unite;
- point or area type: Convert To Area Type / Convert To Point Type
  beside Create Outlines.

Buttons may now carry command parameters. "Edit Path" is translated in
every catalog; the other labels reuse the menu's. cargo xtask ci passes
all 9 steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:11:39 +07:00
alpha-rock 039a467dcb M9: add multiple colored lights to 3D effects
Add opaque RGB light color and up to ten independently adjustable lights to
Revolve, Extrude & Bevel, Inflate and Rotate. Keep light selection, Add/Delete,
selected-light gizmos and preview transactions on the existing command path.

Preserve legacy white-light Inflate polygons, paint composition and seam joins,
including the 0.05 seam minimum. Use shared smooth normals for colored Inflate,
and batch solid-fill RGB channel paths behind one surface clip. Keep source
paths editable and SVG/PDF output vector artwork.

Rebase onto main f7de3d6a, retaining both sides of progress docs and all 22
catalogs. Shade freeform points once through the shared paint mapper. Document
release measurements and a reproducible CLI fixture, including the remaining
source-gradient cost and the one-light timing tradeoff.

Validation: full cargo xtask ci passed all 10 steps; 7,978 tests passed, zero
failed and 11 ignored. Four legacy white-light PNGs match unchanged main byte
for byte. Three paired release bench/perf repetitions completed without busy
warnings; all six standard perf runs passed every budget. Two-light solid
Inflate improved from 136.2 to 96.8 ms/frame at 1200x1200, default eight threads.
2026-10-18 01:11:39 +07:00
EricKuckandClaude Opus 5.5 1f304f6a23 Shadows and glows: each is of the art with the ones before it in the stack, and an Outer Glow blurs a solid silhouette
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:11:39 +07:00
makesomethingshit 45c2b0b363 fix(blend): complete contribution checks (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit 85afe7caaf test(blend): preserve Shift reshape after main rebase (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit 92e33b1579 fix(blend): preserve Anchor Point picking priority (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit fa9a52bd71 fix(blend): match cursor, Shift radius and hidden-handle cases (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit 5f70f96a66 style(tools): match rustfmt in rebased spine anchor test (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit 10ae4cc5a8 fix(blend): align spine anchor picking, Shift drag and handle visibility (#2544)
- Prefer the anchor over a short handle within tolerance, like hit_handle.
- Pick the nearest eligible spine point and handle.
- Constrain Shift handle drags to 45-degree steps round the anchor.
- Hide spine handles with handles_multiple off, in overlays and picking.
2026-10-18 01:11:39 +07:00
makesomethingshit eeeb9645c1 docs(blend): document Anchor Point tool spine conversions (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit e1a6e373d5 docs(blend): document Anchor Point tool spine conversions (#2544) 2026-10-18 01:11:39 +07:00
makesomethingshit 286a1fb8b3 test(tools): cover blend spine anchor conversion and handle focus (#2544) 2026-10-18 01:11:39 +07:00
MarekSpodBiedry 949473f1e3 Magic Wand: only select objects inside the isolated group (#2655) 2026-10-18 01:11:38 +07:00
MarekSpodBiedry f999688b41 Map the WMF window onto the placeable box (#2673) 2026-10-18 01:11:38 +07:00
MarekSpodBiedry b5164825d6 Write legacy .ai layer names in Windows Latin-1 and warn when a character is replaced (#2693) 2026-10-18 01:11:38 +07:00
Neil Verschoore 56df1a1663 i18n(nl): complete the Dutch catalog
Adds the 39 rows the Dutch catalog was missing compared with the other complete languages.
2026-10-18 01:11:38 +07:00
bflatastic 6a42b3bf10 Fonts: Han and kana keep the bundled fallback order; only the weight and slant of fallback glyphs change (#2491) 2026-10-18 01:11:38 +07:00
makesomethingshit 5b85ca6358 test(blend): pin symmetric spine handles and invalid conversion inputs (#2544) 2026-10-18 01:11:38 +07:00
makesomethingshit e2b4cef111 fix(blend): route Anchor Point tool through spine anchors and handles (#2544) 2026-10-18 01:11:38 +07:00
makesomethingshit f92c622e97 fix(blend): add symmetric spine anchor conversion command (#2544) 2026-10-18 01:11:38 +07:00
vangel76andClaude Opus 5.5 43356a1cfb Fonts: fallback glyphs keep the weight and slant, Bold Italic of a missing family keeps the bold, Han and kana prefer an installed sans (#2491)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:09:54 +07:00
vangel76andClaude Opus 5.5 b55d277b49 Fonts: a variable font's default face is named and weighed by its default instance (#2521)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-18 01:09:53 +07:00
Guillem Hermida 2a5dfa6a94 Fix #1876: Artboard Options has X and Y with a reference point
Artboard Options showed Name, Width and Height only: the generic form
hides x and y, which in the shape dialogs are the click position. The
artboard's position could be dragged but not typed.

The dialog now draws its own rows after the form's, in the same grid:
the reference point locator the Transform panel, the Control bar and
Properties share, then X and Y. They show where the reference point
lies, counted from the ruler origin (#1212), as the Transform panel and
the Control bar show an artboard's position. A typed X or Y moves the
artboard so that its reference point lands there, and a new Width or
Height keeps the reference point in place. The fields still hold the
top-left corner in document points, so OK runs artboard.setProps as
before, in one undo step. The shape dialogs keep their x and y hidden.

form::grid_then lets a dialog add rows to the generic grid.
2026-10-18 01:09:52 +07:00
VictorandClaude Opus 5.5 6190525482 G4: Paragraph panel menu › Roman Hanging Punctuation
The Paragraph panel menu's Roman Hanging Punctuation was a disabled stub.

It is now a per-paragraph attribute: in a left-to-right paragraph of point
or area type with it on, the punctuation Optical Margin Alignment hangs
(quotation marks, hyphens, full stops and commas wholly; asterisks,
tildes, ellipses, en and em dashes, colons and semicolons by half) hangs
outside the margin when it starts or ends a line, on the same sides
(left-aligned lines at their left, right-aligned at their right, centered
and justified at both); letters don't hang. With Optical Margin Alignment
on as well, letters hang too. East Asian punctuation keeps hanging only
through burasagari. The layout shares Optical Margin Alignment's hanging
code, given the share of each glyph that hangs.

text.setFormat takes romanHangingPunctuation; it is off by default, saved
only when on, carried by paragraph styles (New and Redefine capture it)
and turned off by Reset Panel. The panel menu item is a checkbox enabled
with type selected.

Docs: docs/mcp.md, docs/type-parity.md, docs/gaps.md.

Closes #2756.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 01:09:34 +07:00
skylar taylor-barrick 39f9204d57 Build: take craft-docpath from the merged craft-libs rev
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
2026-10-17 12:03:53 -06:00
BFlat f7de3d6a01 Merge pull request #2724 from storytold/pr/batch-1017c
i18n and docs: Image Trace warnings in every complete language; ROADMAP for the evening's landings
2026-10-17 20:03:49 +07:00
aiongg 82e363c73b Build: use craft-docpath from craft-libs 2026-10-17 02:20:38 +00:00
aiongg b05043c759 i18n: translate the new link and file-name messages 2026-10-16 21:00:40 +00:00
aiongg ef5084f48b Links: follow linked file paths only on this computer or the document's share
Paths stored with linked images and placed documents, and the hrefs of
SVG images, go through fileio::document_path (craft-docpath): a relative
path is taken from the document's folder, and only local files or files
on the document's own network share are looked at. Opening a document,
Link Info, Package, the linked-file watch and SVG import all use it; the
watch stats files through fileio, so automation roots apply to it too.

A path that is not followed counts as missing; document.open,
links.check and links.info say why in `refused`.
2026-10-16 17:55:48 +00:00
aiongg 1ddfb56f66 Attributes: the Browser button opens http, https and mailto URLs only
attributes.openUrl refuses other URL schemes with a message. It and
links.editOriginal hand a URL or file to another app, so they answer a
control or MCP request (and synthetic input) with an error; a placed
document still opens in a tab.
2026-10-16 16:52:19 +00:00
aiongg b46ffd0059 Links: Edit Original opens only regular files of a format VectorCraft reads
A folder or app bundle, a link to another kind of file, or a file with
any other extension is refused with a message instead of being handed to
the system's default app. On Windows, a path containing a quotation mark
is refused before it reaches Explorer.
2026-10-16 16:39:46 +00:00
320 changed files with 25104 additions and 1530 deletions

No files matched your search

Generated
+40 -28
View File
@@ -806,6 +806,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-pdf"
version = "0.1.0"
@@ -4507,7 +4512,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4531,6 +4536,7 @@ dependencies = [
"vectorcraft-engine",
"vectorcraft-text",
"vectorcraft-ui-egui",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"webbrowser",
@@ -4538,11 +4544,12 @@ dependencies = [
"winit",
"winresource",
"winsafe",
"zbus",
]
[[package]]
name = "vectorcraft-affinity"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"crc32fast",
"flate2",
@@ -4553,7 +4560,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4565,7 +4572,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4581,7 +4588,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"log",
"objc2-core-foundation",
@@ -4603,7 +4610,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4615,8 +4622,9 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"hayro-ccitt",
"image",
"jpeg-encoder",
"kurbo 0.13.1",
@@ -4632,7 +4640,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4648,14 +4656,16 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"craft-docpath",
"crc32fast",
"flate2",
"image",
"kurbo 0.13.1",
"log",
"proptest",
"roxmltree 0.21.1",
"ruzstd",
"serde",
"serde_json",
@@ -4684,7 +4694,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"fax",
"flate2",
@@ -4707,7 +4717,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"flate2",
"proptest",
@@ -4722,7 +4732,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4734,7 +4744,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"log",
@@ -4753,7 +4763,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4767,7 +4777,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4779,7 +4789,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"aes",
"craft-pdf",
@@ -4809,7 +4819,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"serde",
"serde_json",
@@ -4823,7 +4833,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"flate2",
"gif",
@@ -4850,15 +4860,16 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"craft-docpath",
"flate2",
"image",
"kurbo 0.13.1",
@@ -4881,8 +4892,9 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"fax",
"image",
"kurbo 0.13.1",
"proptest",
@@ -4898,7 +4910,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -4918,7 +4930,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -4926,7 +4938,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4942,7 +4954,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4954,7 +4966,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"craft-ui",
"egui",
@@ -4988,7 +5000,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"eframe",
"egui",
@@ -6098,7 +6110,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.12.0"
version = "0.13.1"
dependencies = [
"serde_json",
]
+2 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.12.0"
version = "0.13.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -13,6 +13,7 @@ repository = "https://github.com/storytold/vectorcraft"
[workspace.dependencies]
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
+5 -3
View File
@@ -194,8 +194,10 @@ drag-and-drop, set **Preferences › Performance › Linux Display Server** to *
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
respond to it yet (#491). Open the window through XWayland instead, with the same preference or `WAYLAND_DISPLAY= vectorcraft` (see
On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's
pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse: set the same
preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use XWayland only when a pen is
attached as VectorCraft starts, and restart; or start the app with `WAYLAND_DISPLAY= vectorcraft` (see
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
### Use it from Claude Code and other agents
@@ -236,7 +238,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek and Czech;
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against Illustrator's public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
+10 -3
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-17 · **Change:** minor (about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (Background Export progress dialog with Cancel, #2564; about 85 community PRs of 2026-10-18: Justification…, Word .docx placed as area type, CCITT Group 3 TIFF, Roman Hanging Punctuation, blend spines edited with the Anchor Point tool, shadows and glows in stack order, coloured 3D lights, a Dutch catalog completed; 3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -112,8 +112,8 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
| Korean | `ko` | full | 100% |
| Vietnamese | `vi` | full | 100% |
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish and Ukrainian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
No catalog has had a native speaker's review. Details: [localization-parity.md](docs/localization-parity.md).
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
The Georgian catalog has had a native speaker's review; no other catalog has. Details: [localization-parity.md](docs/localization-parity.md).
## Upcoming
@@ -134,6 +134,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). |
| 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. |
| 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from theLine truncated
| 2026-10-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. |
@@ -162,6 +164,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| 2026-10-12 | A `.ai` file with placed (linked) pictures opens from its editing data, with its guides, sublayers and hidden objects: each picture is a linked image in its place, showing the low-resolution preview the file keeps until its file is found or relinked (#1264). Scores unchanged. |
| 2026-10-12 | Desktop printing adopts the shared `craft-print` spooler with exact physical sheet sizes. Windows 7 retains its WMI/viewer transport and excludes the shared backend; compatibility CI checks the dependency tree and PE imports. Scores unchanged. |
| 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undLine truncated
| 2026-10-12 | Georgian interface (`ka`, ქართული): the whole catalog, plurals and messages, tested complete; Georgian letters come from an installed font (SF Georgian, Sylfaen, Noto Sans Georgian, DejaVu Sans) from the first frame; Type › Change Case › Title Case and Sentence case leave Georgian lower case instead of turning it into Mtavruli. |
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
@@ -179,6 +182,10 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
| 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged |
| 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged |
| 2026-10-17 | minor | Progress log row for the Background Export progress dialog (#2564); headline numbers unchanged. |
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
| 2026-10-14 | minor | Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. |
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
+32 -10
View File
@@ -78,8 +78,8 @@ USAGE:
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export each FILE in the format its extension picks (see Writable formats). Prints one
JSON result per step.
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
is a template, written as file.saveAsTemplate writes it). Prints one JSON result per step.
vectorcraft-cli commands
Print the command catalogue as JSON.
@@ -88,6 +88,8 @@ USAGE:
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
--artboard or --range).
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
precision (1–7, default 3). OUT may not be IN when reading
@@ -259,6 +261,10 @@ fn convert(args: &[String]) -> Result<(), String> {
}
}
let [input, output] = <[String; 2]>::try_from(files).map_err(|_| "convert needs IN and OUT")?;
let template = is_template(&output);
if template && (artboard.is_some() || range.is_some()) {
return Err("a .ait template holds every artboard: --artboard and --range don't apply".into());
}
let mut h = Headless::new();
let opened = if editing_data {
let params = json!({"path": input, "editingData": "always"});
@@ -266,16 +272,22 @@ fn convert(args: &[String]) -> Result<(), String> {
} else {
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?
};
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
let (command, mut params) = if template {
// Save as Template writes every artboard, with the .ai options.
("file.saveAsTemplate", json!({"path": output, "format": "ait", "outlineText": outline_text, "acknowledgeLoss": replace_lossy}))
} else {
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let mut params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
if let Some(decimals) = decimals {
params["decimals"] = json!(decimals);
}
("document.export", params)
};
for (key, value) in legacy {
params[key] = json!(value);
}
let mut r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
let mut r = h.call("engine.execute", json!({"command": command, "params": params})).map_err(|e| format!("export {output}: {e}"))?;
// What reading the input left out (a missing linked file, an unsupported filter…) matters as
// much as what writing the output did.
if let (Some(w), Some(out)) = (opened.get("warnings").filter(|w| w.as_array().is_some_and(|a| !a.is_empty())), r.as_object_mut()) {
@@ -285,6 +297,11 @@ fn convert(args: &[String]) -> Result<(), String> {
Ok(())
}
/// Is `path` a `.ait` template? `file.saveAsTemplate` writes it; exports don't.
fn is_template(path: &str) -> bool {
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
}
fn info(args: &[String]) -> Result<(), String> {
let file = args.first().ok_or("info needs a FILE")?;
let mut h = Headless::new();
@@ -371,7 +388,12 @@ fn run(args: &[String]) -> Result<(), String> {
r
}
Step::Export(path) => {
let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?;
let r = if is_template(&path) {
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
} else {
h.call("app.export", json!({"path": path, "scale": scale}))
};
let r = r.map_err(|e| format!("export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?;
r
}
+25
View File
@@ -179,3 +179,28 @@ fn convert_writes_legacy_ai_with_its_options() {
assert!(!bad.status.success());
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
}
/// convert and `run --export` write a `.ait` as Save as Template does: a PDF-compatible template of
/// every artboard that opens as a new untitled document.
#[test]
fn convert_and_run_write_ait_templates() {
let pdf = tmp("template-in.pdf");
make(&pdf, 120, 80);
let (converted, exported) = (tmp("converted.ait"), tmp("exported.ait"));
let r: Value = serde_json::from_str(&ok(&["convert", &pdf, &converted])).unwrap();
assert_eq!((&r["format"], &r["path"]), (&Value::from("ait"), &Value::from(converted.as_str())), "{r}");
let r: Value = serde_json::from_str(ok(&["run", "--in", &pdf, "--export", &exported]).lines().nth(1).unwrap()).unwrap();
assert_eq!(r["result"]["format"], "ait", "{r}");
for ait in [&converted, &exported] {
assert!(std::fs::read(ait).unwrap().starts_with(b"%PDF"), "{ait}");
let opened: Value = serde_json::from_str(ok(&["run", "--in", ait]).lines().next().unwrap()).unwrap();
let opened = &opened["result"];
assert_eq!((&opened["format"], &opened["restored"]), (&Value::from("ait"), &Value::from(true)), "{opened}");
assert!(opened["title"].as_str().is_some_and(|t| t.starts_with("Untitled-")), "{opened}");
assert_eq!(info(ait)["artboards"].as_array().unwrap().len(), 2, "{ait}");
}
// A template holds every artboard: one can't be picked.
let one = Command::new(BIN).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap();
assert!(!one.status.success());
assert!(String::from_utf8_lossy(&one.stderr).contains("every artboard"), "{}", String::from_utf8_lossy(&one.stderr));
}
+13 -1
View File
@@ -75,7 +75,17 @@ pollster = "1"
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
# so Automatic draws on that one. Both are already in the build through winit.
wayland-client = "0.31"
wayland-protocols = { version = "0.32", features = ["client"] }
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
# through winit and rfd.
wayland-backend = { version = "0.3", features = ["client_system"] }
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
# exactly: review the diff before bumping.
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
zbus = "=5.19.0"
[build-dependencies]
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
@@ -84,3 +94,5 @@ winresource = "0.1"
[dev-dependencies]
# Bound socket buffers in real loopback transport deadline regressions.
socket2 = { workspace = true }
# src/portal_save.rs's tests build and read the portal's D-Bus values on every system.
zbus = "=5.19.0"
+4 -1
View File
@@ -383,7 +383,10 @@ mod tests {
});
let mut reader = BufReader::new(client.try_clone().unwrap());
for id in [1, 2] {
writeln!(client, "{}", json!({"id": id, "method": "inspect"})).unwrap();
// The client sends each line in one write. `writeln!` on the socket sends the JSON in many
// small writes, and when the machine is busy, a pause of more than the 120 ms line deadline
// between two of them closes the connection.
client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
+33 -7
View File
@@ -43,6 +43,8 @@ mod mac_screen_color;
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd", test))]
mod portal_save;
mod prefs_dir;
mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
@@ -359,10 +361,20 @@ fn make_folder(request: &PickRequest) {
}
}
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. On
/// Linux and the BSDs a save dialog with two or more file types is the desktop portal's, over
/// `parent`, and its answer also names the file type picked ([`portal_save`]); `dialog` shows when
/// the portal can't.
#[cfg(not(target_os = "macos"))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
#[cfg_attr(windows, allow(unused_variables))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> {
make_folder(request);
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
if let PickRequest::Save(pick) = request
&& let Some(answer) = portal_save::pick(pick, parent.as_deref())
{
return answer;
}
let paths = match request {
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
@@ -375,7 +387,7 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
/// Show the dialog for `request` over `parent` now → the paths picked.
#[cfg(not(target_os = "macos"))]
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request)
show_dialog(file_dialog(&request, parent), &request, parent)
}
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
@@ -389,6 +401,7 @@ fn pick_now(request: PickRequest, _parent: &Parent) -> Vec<String> {
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
explorer_safe(path)?;
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
}
@@ -430,11 +443,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
let ui = ui.clone();
let parent = parent.clone();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request));
let _ = tx.send(show_dialog(dialog, &request, &parent));
ui.request_repaint();
})
.ok()?;
@@ -559,8 +573,15 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
}
}
/// Windows passes `path` to Explorer inside quotes it adds itself, so a path holding a quote (which
/// no Windows file name has) is refused rather than split into other arguments.
fn explorer_safe(path: &str) -> Result<(), String> {
if cfg!(windows) && path.contains('"') { Err(format!("can't open {path}: the path contains a quotation mark")) } else { Ok(()) }
}
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
fn open_file(path: &str) -> Result<(), String> {
explorer_safe(path)?;
#[cfg(windows)]
let mut c = {
use std::os::windows::process::CommandExt as _;
@@ -788,14 +809,19 @@ fn main() -> std::process::ExitCode {
let custom_titlebar = custom_titlebar(saved.as_ref());
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151).
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
// and X11 (XWayland) When a Pen Is Attached does so when a pen is attached, where the pen draws (#2028).
#[cfg(target_os = "linux")]
let options = {
let mut options = options;
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
if xwayland::wanted(pref, |name| std::env::var_os(name)) {
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
if xwayland::x11_libraries_found() {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
if pref == Some("x11WithPen") {
log::info!("pen found: opening the window through XWayland (Preferences › Performance › Linux Display Server)");
} else {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
}
use winit::platform::x11::EventLoopBuilderExtX11 as _;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_x11();
+529
View File
@@ -0,0 +1,529 @@
//! Linux and the BSDs: the save dialog of a save with two or more file types (Save, Save As, Save a
//! Copy, Save as Template; #2679), asked of the XDG desktop portal here
//! (<https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html>).
//! rfd 0.17 sends no `current_filter` with `SaveFile` and reads only `uris` from the answer
//! (PolyMeilex/rfd#341), and the GTK file chooser behind the portal keeps the typed name when the
//! user picks another file type, so the type picked was lost. This call sends the first file type as
//! `current_filter`, reads the one picked back, and answers its first extension after the path
//! ([`vectorcraft_ui_egui::picks::PickAnswer`]); the app gives the name that type's extension.
//! rfd shows the dialog when the portal can't (no session bus, no file chooser), with zenity as its
//! own fallback. PhotoCraft's `portal_save.rs` (storytold/photocraft#6151) makes the same call.
//!
//! The dialog's parent is the window: `x11:<window id>` on X11, and on Wayland (Linux) a handle
//! from the compositor's xdg-foreign protocol, as rfd names it (its `window_identifier`).
use std::collections::HashMap;
use vectorcraft_ui_egui::FilePick;
use zbus::zvariant::{OwnedValue, Value};
/// A file type in the dialog: its label and its extensions without the dot.
type Filter = (String, Vec<String>);
/// A save dialog to show.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct SaveRequest {
/// The portal's identifier of the parent window, or empty.
parent: String,
/// The suggested name; empty for none.
name: String,
folder: Option<String>,
/// The file types, labeled in the interface's language; the dialog starts on the first.
filters: Vec<Filter>,
}
impl SaveRequest {
/// The dialog for `pick` over the window `parent` names.
fn new(pick: &FilePick, parent: String) -> Self {
let lang = vectorcraft_ui_egui::i18n::current();
let filters = pick
.filters
.iter()
.map(|(label, exts)| {
(vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", label).to_string(), exts.iter().map(|e| e.to_string()).collect())
})
.collect();
Self { parent, name: pick.name.clone(), folder: pick.folder.clone(), filters }
}
}
/// What the dialog answered: the path, as typed, and the label of the file type picked.
#[derive(Clone, Debug, PartialEq, Eq)]
struct Saved {
path: String,
filter: Option<String>,
}
/// `filter` as the portal takes it: its label and a `*.ext` pattern for each extension (`*` for
/// every file), as rfd sends them.
fn wire(filter: &Filter) -> (String, Vec<(u32, String)>) {
let globs = filter.1.iter().map(|e| (0, if e.is_empty() || e == "*" { "*".to_string() } else { format!("*.{e}") })).collect();
(filter.0.clone(), globs)
}
/// The `SaveFile` options for `request`, answering on `token`.
fn options<'a>(request: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> {
let mut o = HashMap::from([("handle_token", Value::from(token))]);
if !request.name.is_empty() {
o.insert("current_name", Value::from(request.name.as_str()));
}
if let Some(folder) = &request.folder {
// A byte string ending in NUL, as the portal specifies.
let mut bytes = folder.as_bytes().to_vec();
bytes.push(0);
o.insert("current_folder", Value::from(bytes));
}
if let Some(first) = request.filters.first() {
o.insert("filters", Value::from(request.filters.iter().map(wire).collect::<Vec<_>>()));
o.insert("current_filter", Value::from(wire(first)));
}
o
}
/// The portal's `Response`: code 0 with the first of its `uris` and the label of its
/// `current_filter`. Any other code ends the dialog without a file (1 is Cancel, 2 a dialog closed
/// some other way), as rfd reads it.
fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Saved>, String> {
if code != 0 {
return Ok(None);
}
let uris: Vec<String> = results
.get("uris")
.ok_or("no file in the portal's answer")?
.try_clone()
.map_err(|e| e.to_string())?
.try_into()
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
let uri = uris.first().ok_or("no file in the portal's answer")?;
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
let filter = results.get("current_filter").and_then(|v| {
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
Ok(Some(Saved { path, filter }))
}
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg`; `None` for a file elsewhere (`file://host/`)
/// or another scheme.
fn file_uri_path(uri: &str) -> Option<String> {
let rest = uri.strip_prefix("file://")?;
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
return None;
}
let mut out = Vec::with_capacity(rest.len());
let mut rest = rest.as_bytes();
while let Some((&b, tail)) = rest.split_first() {
let hex = tail.get(..2).filter(|h| b == b'%' && h.iter().all(u8::is_ascii_hexdigit));
match hex.and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) {
Some(decoded) => {
out.push(decoded);
rest = tail.get(2..).unwrap_or_default();
}
None => {
out.push(b);
rest = tail;
}
}
}
// `show_dialog` makes rfd's path a string with `to_string_lossy` too.
Some(String::from_utf8_lossy(&out).into_owned())
}
/// `saved` as the answer of the save dialog for `request`: the path, then the first extension of
/// the file type picked, found by its label. Empty when the dialog was canceled.
fn pick_answer(saved: Option<Saved>, request: &SaveRequest) -> Vec<String> {
let Some(saved) = saved else { return Vec::new() };
let picked = saved.filter.and_then(|label| request.filters.iter().find(|(l, _)| *l == label)).and_then(|(_, exts)| exts.first().cloned());
std::iter::once(saved.path).chain(picked).collect()
}
/// The portal's identifier of an X11 window.
fn x11_parent(window: impl std::fmt::LowerHex) -> String {
format!("x11:{window:x}")
}
/// Show the portal's save dialog for `pick` over `window` and wait for it → the answer (see the
/// module's comment). `None` when `pick` offers fewer than two file types, or the portal couldn't
/// show the dialog; rfd shows it then.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option<Vec<String>> {
if pick.filters.len() < 2 {
return None;
}
// The export stays until the dialog has answered: the handle is valid while it lives.
let (parent, _export) = window.map(parent).unwrap_or_default();
let request = SaveRequest::new(pick, parent);
let saved = zbus::blocking::Connection::session().map_err(|e| e.to_string()).and_then(|conn| save_file(&conn, &request));
match saved {
Ok(saved) => Some(pick_answer(saved, &request)),
Err(e) => {
log::info!("the portal's save dialog failed ({e}): rfd shows it");
None
}
}
}
/// Ask the portal on `conn` for the save dialog of `request` and wait for its answer.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
use std::sync::atomic::{AtomicU32, Ordering};
use zbus::blocking::Proxy;
use zbus::zvariant::OwnedObjectPath;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
const REQUEST: &str = "org.freedesktop.portal.Request";
static NEXT: AtomicU32 = AtomicU32::new(0);
let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_");
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
// Listen before asking, so that an answer that comes at once is not missed.
let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?;
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
// The title is empty, as rfd sends it for a save.
let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
// A portal older than version 0.9 ignores `handle_token` and answers on the request path it
// returns.
let reply = if handle.as_str() == path {
replies.next()
} else {
Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next()
}
.ok_or("the save dialog closed without an answer")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results)
}
/// The portal's identifier of `window` when it is an X11 window.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn x11_window(window: &winit::window::Window) -> Option<String> {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
match window.window_handle().ok()?.as_raw() {
RawWindowHandle::Xlib(h) => Some(x11_parent(h.window)),
RawWindowHandle::Xcb(h) => Some(x11_parent(h.window.get())),
_ => None,
}
}
/// The portal's identifier of `window`, and on Wayland the export that keeps it valid; empty when
/// the window has neither an X11 id nor a Wayland handle.
#[cfg(target_os = "linux")]
fn parent(window: &winit::window::Window) -> (String, Option<wayland::Export>) {
if let Some(id) = x11_window(window) {
return (id, None);
}
match wayland::export(window) {
Some(export) => (format!("wayland:{}", export.handle), Some(export)),
None => (String::new(), None),
}
}
/// The portal's identifier of `window`: its X11 id, else empty (no Wayland export is made off
/// Linux).
#[cfg(any(target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn parent(window: &winit::window::Window) -> (String, Option<()>) {
(x11_window(window).unwrap_or_default(), None)
}
/// The Wayland window's handle for the portal: an xdg-foreign v2 export of its surface
/// (<https://wayland.app/protocols/xdg-foreign-unstable-v2>), made as rfd's `window_identifier`
/// makes it.
#[cfg(target_os = "linux")]
mod wayland {
use wayland_client::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::protocol::wl_surface::WlSurface;
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exported_v2::{self, ZxdgExportedV2};
use wayland_protocols::xdg::foreign::zv2::client::zxdg_exporter_v2::{self, ZxdgExporterV2};
/// A window exported for the portal: `handle` names it until this is dropped.
pub struct Export {
pub handle: String,
exported: ZxdgExportedV2,
conn: Connection,
}
impl Drop for Export {
fn drop(&mut self) {
self.exported.destroy();
let _ = self.conn.flush();
}
}
/// The events the export waits for.
#[derive(Default)]
struct State {
handle: Option<String>,
}
impl Dispatch<WlRegistry, GlobalListContents> for State {
fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle<Self>) {}
}
impl Dispatch<ZxdgExporterV2, ()> for State {
fn event(_: &mut Self, _: &ZxdgExporterV2, _: zxdg_exporter_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
}
impl Dispatch<ZxdgExportedV2, ()> for State {
fn event(state: &mut Self, _: &ZxdgExportedV2, event: zxdg_exported_v2::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
if let zxdg_exported_v2::Event::Handle { handle } = event {
state.handle = Some(handle);
}
}
}
/// Export `window`'s surface on the window's own Wayland connection, with an event queue of its
/// own → `None` when the compositor has no xdg-foreign v2 or answers no handle.
pub fn export(window: &winit::window::Window) -> Option<Export> {
let (conn, surface) = surface(window)?;
let (globals, mut queue) = registry_queue_init::<State>(&conn).ok()?;
let qh = queue.handle();
let exporter: ZxdgExporterV2 = globals.bind(&qh, 1..=1, ()).ok()?;
let exported = exporter.export_toplevel(&surface, &qh, ());
let mut state = State::default();
let answered = queue.roundtrip(&mut state);
exporter.destroy();
match (answered, state.handle) {
(Ok(_), Some(handle)) => Some(Export { handle, exported, conn }),
_ => {
exported.destroy();
let _ = conn.flush();
log::info!("the compositor gave no xdg-foreign handle: the save dialog has no parent window");
None
}
}
}
/// `window`'s Wayland connection and surface, from its raw handles.
#[allow(unsafe_code)]
fn surface(window: &winit::window::Window) -> Option<(Connection, WlSurface)> {
use wayland_backend::sys::client::{Backend, ObjectId};
use winit::raw_window_handle::{HasDisplayHandle as _, HasWindowHandle as _, RawDisplayHandle, RawWindowHandle};
let (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) =
(window.window_handle().ok()?.as_raw(), window.display_handle().ok()?.as_raw())
else {
return None;
};
// SAFETY: the display is winit's open `wl_display`, which outlives `window` (the caller
// holds it until the export is dropped); a backend made from a foreign display does not
// close it when dropped.
let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) };
let conn = Connection::from_backend(backend);
// SAFETY: the pointer is the window's live `wl_surface` proxy on that display.
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), w.surface.as_ptr().cast()) }.ok()?;
let surface = WlSurface::from_id(&conn, id).ok()?;
Some((conn, surface))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request() -> SaveRequest {
let pick = FilePick {
name: "Untitled-1.vectorcraft".into(),
folder: Some("/home/me/Art".into()),
filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])],
};
SaveRequest::new(&pick, "x11:3a0000f".into())
}
/// The dialog starts on the first file type (the document's format) and shows the suggested
/// name in its folder.
#[test]
fn the_request_names_the_file_type_the_dialog_starts_on() {
let request = request();
let o = options(&request, "t1");
assert_eq!(o.get("current_filter"), Some(&Value::from(("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]))));
let filters = vec![
("VectorCraft".to_string(), vec![(0u32, "*.vectorcraft".to_string())]),
("SVG".to_string(), vec![(0u32, "*.svg".to_string())]),
("PDF-compatible .ai".to_string(), vec![(0u32, "*.ai".to_string())]),
];
assert_eq!(o.get("filters"), Some(&Value::from(filters)));
assert_eq!(o.get("current_name"), Some(&Value::from("Untitled-1.vectorcraft")));
assert_eq!(o.get("current_folder"), Some(&Value::from(b"/home/me/Art\0".to_vec())));
assert_eq!(o.get("handle_token"), Some(&Value::from("t1")));
let bare = SaveRequest { name: String::new(), folder: None, filters: Vec::new(), ..request };
let o = options(&bare, "t2");
assert_eq!(o.keys().copied().collect::<Vec<_>>(), ["handle_token"]);
}
/// The portal's answer as a D-Bus dictionary: `uris` and `current_filter`.
fn results(uri: &str, filter: Option<&str>) -> HashMap<String, OwnedValue> {
let mut r = HashMap::new();
r.insert("uris".to_string(), OwnedValue::try_from(Value::from(vec![uri.to_string()])).unwrap());
if let Some(label) = filter {
let picked = Value::from((label.to_string(), vec![(0u32, "*.ai".to_string())]));
r.insert("current_filter".to_string(), OwnedValue::try_from(picked).unwrap());
}
r
}
/// The file type picked comes back with the path as typed, and its first extension follows
/// the path in the answer.
#[test]
fn the_answer_carries_the_file_type_picked() {
let request = request();
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PDF-compatible .ai"))).unwrap();
assert_eq!(saved, Some(Saved { path: "/home/me/Art/poster".into(), filter: Some("PDF-compatible .ai".into()) }));
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
// A portal that names no file type, or one the dialog did not offer: the path alone.
let saved = answer(0, &results("file:///home/me/Art/poster.svg", None)).unwrap();
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster.svg"]);
let saved = answer(0, &results("file:///home/me/Art/poster", Some("PNG"))).unwrap();
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]);
}
/// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file
/// is an error, and rfd shows the dialog instead.
#[test]
fn a_canceled_dialog_answers_no_file() {
let request = request();
for code in [1, 2] {
assert_eq!(answer(code, &HashMap::new()), Ok(None));
assert!(pick_answer(None, &request).is_empty());
}
assert!(answer(0, &HashMap::new()).is_err());
assert!(answer(0, &results("sftp://host/poster.svg", Some("SVG"))).is_err());
}
#[test]
fn file_uris_become_local_paths() {
assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft"));
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg"));
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf"));
// A `%` without two hex digits after it stays as it is.
assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2"));
assert_eq!(file_uri_path("file://host/x.svg"), None);
assert_eq!(file_uri_path("sftp://host/x.svg"), None);
}
#[test]
fn x11_windows_are_named_by_their_id() {
assert_eq!(x11_parent(0x3a0000f), "x11:3a0000f");
}
/// What the mock portal answers to each `SaveFile` call: the response code, and the file and
/// the label of the file type "picked".
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
type Script = Vec<(u32, Option<(&'static str, &'static str)>)>;
/// What each `SaveFile` call carried: `current_name` and the label of `current_filter`.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(Option<String>, Option<String>)>>>;
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
/// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths`
/// it is a portal older than 0.9, which answers on a request path of its own. `None` when there
/// is no `dbus-daemon` to run.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead as _;
use zbus::zvariant::OwnedObjectPath;
let mut daemon = std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn()
.ok()?;
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
let server =
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
let seen = Seen::default();
let log = seen.clone();
std::thread::spawn(move || {
let mut script = script.into_iter();
for msg in zbus::blocking::MessageIterator::from(&server) {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
continue;
}
let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap<String, OwnedValue>)>() else { break };
let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok());
let filter = options.get("current_filter").and_then(|v| {
let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
Some(label)
});
let calls = log.lock().map(|mut l| {
l.push((text("current_name"), filter));
l.len()
});
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() };
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
if server.reply(&header, &handle).is_err() {
break;
}
if own_paths {
// As a person answers the dialog: after the caller listened on the path returned.
std::thread::sleep(std::time::Duration::from_millis(300));
}
let (code, pick) = script.next().unwrap_or((2, None));
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
if let Some((uri, label)) = pick {
results.insert("uris", Value::from(vec![uri.to_string()]));
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
}
let to = header.sender().cloned().map(zbus::names::BusName::from);
if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() {
break;
}
}
});
let client = zbus::blocking::connection::Builder::address(address.as_str()).ok()?.build().ok()?;
Some((client, seen, daemon))
}
/// Through a real D-Bus: the call starts the dialog on the first file type with the suggested
/// name, and the answer names the file and the type picked; Cancel answers no file.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
let Some((conn, seen, mut daemon)) = mock_portal(script, false) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
let saved = save_file(&conn, &request).unwrap();
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
assert_eq!(save_file(&conn, &request), Ok(None));
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
let _ = daemon.kill();
}
/// A portal older than 0.9 answers on the request path it returns, and the answer is awaited
/// there.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
let _ = daemon.kill();
}
/// The labels go to the dialog in the interface's language, which is how the answer is matched.
#[test]
fn the_file_types_are_labeled_as_the_other_dialogs_label_them() {
let request = request();
let labels: Vec<&str> = request.filters.iter().map(|(l, _)| l.as_str()).collect();
let lang = vectorcraft_ui_egui::i18n::current();
let expected: Vec<&str> =
["VectorCraft", "SVG", "PDF-compatible .ai"].iter().map(|l| vectorcraft_ui_egui::i18n::tr_ctx(lang, "file-filter", l)).collect();
assert_eq!(labels, expected);
assert_eq!(request.parent, "x11:3a0000f");
}
}
+129 -17
View File
@@ -1,11 +1,15 @@
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
//! #1151). Only when XWayland can run the window: an X display is set and the X11 libraries winit
//! loads at runtime are installed. Otherwise the window opens on Wayland, so a missing library
//! never keeps the app from starting.
//! #1151). X11 (XWayland) When a Pen Is Attached does so only when a pen is attached as the app
//! starts: winit 0.30 doesn't bind the Wayland tablet protocol, and XWayland hands the pen to X11
//! windows as a pointer (#2028). Only when XWayland can run the window: an X display is set and
//! the X11 libraries winit loads at runtime are installed. Otherwise the window opens on Wayland,
//! so a missing library never keeps the app from starting.
//!
//! The library check comes from PhotoCraft's `linux_libs.rs`: no `dlopen` (that needs `unsafe`),
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories.
//! just the dynamic linker's cache (`ldconfig -p`) and the usual library directories. The pen
//! check comes from PhotoCraft's `tablet.rs`: the input devices the kernel lists in
//! `/proc/bus/input/devices`.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::ffi::OsString;
@@ -16,12 +20,50 @@ use std::path::{Path, PathBuf};
const X11_LIBS: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, a
/// Wayland session (winit picks Wayland whenever `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set)
/// and an X display to open.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>) -> bool {
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, or
/// `x11WithPen` chosen and `pen` finding a pen; a Wayland session (winit picks Wayland whenever
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only for
/// `x11WithPen`, after the session checks.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>, pen: impl FnOnce() -> bool) -> bool {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
pref == Some("x11") && (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY")
let session = (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY");
match pref {
Some("x11") => session,
Some("x11WithPen") => session && pen(),
_ => false,
}
}
/// `BTN_TOOL_PEN` (linux/input-event-codes.h): the key that a pen tablet's stylus, a pen display's
/// pen or a pen-enabled touchscreen's pen reports.
const BTN_TOOL_PEN: u32 = 0x140;
/// `ABS_X` and `ABS_Y` (linux/input-event-codes.h): the absolute axes every pen reports.
const ABS_X: u32 = 0x00;
const ABS_Y: u32 = 0x01;
/// Is a pen attached? Reads `/proc/bus/input/devices`; a file that can't be read has no pen.
pub fn pen_attached() -> bool {
std::fs::read_to_string("/proc/bus/input/devices").is_ok_and(|devices| has_pen(&devices))
}
/// Is a pen among the input devices `/proc/bus/input/devices` lists? A pen is a device whose
/// `B: KEY=` bitmap has `BTN_TOOL_PEN` and whose `B: ABS=` bitmap has `ABS_X` and `ABS_Y`, as
/// libinput asks of a tablet tool. A virtual keyboard made with uinput can claim every key code,
/// `BTN_TOOL_PEN` included, but has no absolute axes.
fn has_pen(devices: &str) -> bool {
devices.split("\n\n").any(|device| {
let bitmap = |name: &str| device.lines().find_map(|l| l.strip_prefix(name));
bitmap("B: KEY=").is_some_and(|keys| bit_set(keys, BTN_TOOL_PEN))
&& bitmap("B: ABS=").is_some_and(|axes| bit_set(axes, ABS_X) && bit_set(axes, ABS_Y))
})
}
/// Bit `bit` of a `/proc/bus/input/devices` bitmap: hex words of the kernel's `long`, most
/// significant first. Short or malformed bitmaps have no bits set.
fn bit_set(bitmap: &str, bit: u32) -> bool {
let bits = usize::BITS;
let word = usize::try_from(bit / bits).unwrap_or(usize::MAX);
bitmap.split_whitespace().rev().nth(word).and_then(|w| u64::from_str_radix(w, 16).ok()).is_some_and(|w| (w >> (bit % bits)) & 1 == 1)
}
/// Are all the libraries an X11 window needs found on this system? Libraries the check can't find
@@ -98,18 +140,88 @@ mod tests {
move |k| pairs.iter().find(|(name, _)| *name == k).map(|(_, v)| OsString::from(v))
}
fn no_probe() -> bool {
panic!("the pen check ran")
}
#[test]
fn xwayland_only_when_chosen_on_wayland_with_an_x_display() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11"), &wayland));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")])));
assert!(!wanted(Some("automatic"), &wayland));
assert!(!wanted(None, &wayland));
assert!(!wanted(Some("wayland"), &wayland));
assert!(wanted(Some("x11"), &wayland, no_probe));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("automatic"), &wayland, no_probe));
assert!(!wanted(None, &wayland, no_probe));
assert!(!wanted(Some("wayland"), &wayland, no_probe));
// An X11 session already opens on X11; without an X display there is no XWayland to use.
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")])));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")])));
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
}
/// X11 (XWayland) When a Pen Is Attached (#2028): XWayland on a Wayland session with an X
/// display when a pen is found, the session's own display server otherwise. The pen check
/// runs only on such a session.
#[test]
fn the_pen_choice_opens_xwayland_only_with_a_pen() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11WithPen"), &wayland, || true));
assert!(wanted(Some("x11WithPen"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true));
assert!(!wanted(Some("x11WithPen"), &wayland, || false));
assert!(!wanted(Some("x11WithPen"), env(&[("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
}
/// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the
/// pen's `KEY=1c03 0 0 0 0 0` has bit 0x140, `BTN_TOOL_PEN`), from PhotoCraft's tests.
const WITH_PEN: &str = "I: Bus=0011 Vendor=0001 Product=0001 Version=ab83\n\
N: Name=\"AT Translated Set 2 keyboard\"\n\
H: Handlers=sysrq kbd event3 leds\n\
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe\n\
\n\
I: Bus=0018 Vendor=06cb Product=ce26 Version=0100\n\
N: Name=\"SYNA2B33:00 06CB:CE26 Touchpad\"\n\
H: Handlers=mouse1 event7\n\
B: KEY=e520 10000 0 0 0 0\n\
B: ABS=2e0800000000003\n\
\n\
I: Bus=0003 Vendor=056a Product=0357 Version=0110\n\
N: Name=\"Wacom Intuos Pro M Pen\"\n\
H: Handlers=mouse2 event10\n\
B: KEY=1c03 0 0 0 0 0\n\
B: ABS=1000f000003\n";
/// A uinput virtual keyboard that claims every key code, `BTN_TOOL_PEN` included, and has no
/// absolute axes (`EV=20013`, no `B: ABS=` line), from PhotoCraft's tests.
const VIRTUAL_KEYBOARD: &str = "I: Bus=0003 Vendor=1234 Product=5678 Version=0111\n\
N: Name=\"Virtual Keyboard\"\n\
H: Handlers=sysrq rfkill kbd event256 leds \n\
B: EV=20013\n\
B: KEY=ffffffffff 0 7ffffff000301ff 1000f7fffffff 7fe001fffff000f 7ffffffffffffff ffffffff0003fdff 7fff8fff00ff03ff 1ffffffffffff07 ffffffffffffffff ffffffffffefffff fffffffffffffffe\n";
#[test]
fn a_pen_is_a_device_with_btn_tool_pen_and_absolute_axes() {
assert!(has_pen(WITH_PEN));
let without = WITH_PEN.split("\n\n").take(2).collect::<Vec<_>>().join("\n\n");
assert!(!has_pen(&without), "a touchpad's BTN_TOOL_FINGER (0x145) is not a pen");
// A pen display reports the pen with its own axes.
assert!(has_pen("N: Name=\"Wacom One Pen\"\nB: KEY=1c03 0 0 0 0 0\nB: ABS=3\n"));
// Axes without ABS_X or ABS_Y, or no axes at all, are no pen.
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000002\n"));
assert!(!has_pen("B: KEY=1c03 0 0 0 0 0\nB: ABS=1000f000001\n"));
let pen_without_axes = WITH_PEN.lines().filter(|l| !l.starts_with("B: ABS=1000f")).collect::<Vec<_>>().join("\n");
assert!(!has_pen(&pen_without_axes));
// The virtual keyboard claims the key but is no pen, and a real pen beside it is still found.
assert!(VIRTUAL_KEYBOARD.lines().find_map(|l| l.strip_prefix("B: KEY=")).is_some_and(|k| bit_set(k, BTN_TOOL_PEN)));
assert!(!has_pen(VIRTUAL_KEYBOARD));
assert!(has_pen(&format!("{VIRTUAL_KEYBOARD}\n{WITH_PEN}")));
// Short or malformed bitmaps are no pen, never a panic.
for keys in ["", "B: KEY=", "B: KEY=1", "B: KEY=zz 0 0 0 0 0", "B: KEY=ffffffffffffffffff 0 0 0 0 0", "B: KEY=0 0 0 0 0 0 0 0"] {
assert!(!has_pen(&format!("{keys}\nB: ABS=3")), "{keys:?}");
}
for abs in ["B: ABS=", "B: ABS=zz", "B: ABS=ffffffffffffffffff", "B: ABS=0 0"] {
assert!(!has_pen(&format!("B: KEY=1c03 0 0 0 0 0\n{abs}")), "{abs:?}");
}
}
#[test]
+3
View File
@@ -126,3 +126,6 @@ sync_github_name = true
[people.dennismutuku2005]
sync_github_name = true
[people.makesomethingshit]
sync_github_name = true
+97 -8
View File
@@ -11,6 +11,7 @@
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
use serde::{Deserialize, Serialize};
use crate::cms::{self, Model};
use crate::{Color, Gradient, GradientStop};
/// The spread a new point gets.
@@ -157,11 +158,12 @@ impl Freeform {
f
}
/// Colour the points along `g`, first to last.
/// Color the points along `g`, first to last, mixing neighboring stops in the model they share
/// ([`Color::lerp_keep_model`]).
pub fn recolor(&mut self, g: &Gradient) {
let n = self.points.len();
for (k, p) in self.points.iter_mut().enumerate() {
(p.color, p.opacity) = g.sample(k as f32 / (n - 1).max(1) as f32);
(p.color, p.opacity) = g.sample_with(k as f32 / (n - 1).max(1) as f32, Color::lerp_keep_model);
}
}
@@ -230,10 +232,10 @@ impl Freeform {
best
}
/// The colour and opacity at `p` for a painted box whose [`spread_scale`] is `scale`.
/// The color and opacity at `p` for a painted box whose [`spread_scale`] is `scale`, as
/// [`Self::model_field`] mixes them.
pub fn sample(&self, p: Point, scale: f64) -> (Color, f32) {
let ([r, g, b], a) = self.field(scale).sample(p);
(Color::rgb(r, g, b), a)
self.model_field(scale).sample(p)
}
/// The colour field, with colours converted and lines tessellated once (for sampling many
@@ -265,6 +267,32 @@ impl Freeform {
Field { points, pieces, eps: 1e-9 * (scale.abs() + 1.0) }
}
/// [`Self::field`] mixing the colors in the model every point shares instead of display RGB:
/// the C, M, Y and K inks (as a CMYK document drawn by ink planes mixes them), the Gray ink,
/// or the RGB or Lab components. Points in different models mix in display RGB, as
/// [`Self::field`] mixes them, and the field gives that color in the working RGB space.
pub fn model_field(&self, scale: f64) -> ModelField {
let Some(model) = self.shared_model() else { return ModelField { model: None, first: self.field(scale), k: None } };
let parts = |c: &Color| match *c {
Color::Rgb { r, g, b } => [r, g, b, 0.0],
Color::Cmyk { c, m, y, k } => [c, m, y, k],
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
Color::Lab { l, a, b } => [l, a, b, 0.0],
};
let first = self.field_with(scale, &|c| {
let [x, y, z, _] = parts(c);
[x, y, z]
});
let k = (model == Model::Cmyk).then(|| self.field_with(scale, &|c| [parts(c)[3], 0.0, 0.0]));
ModelField { model: Some(model), first, k }
}
/// The color model every point is in (`None` without points or when their models differ).
pub fn shared_model(&self) -> Option<Model> {
let m = self.points.first()?.color.model();
self.points.iter().all(|p| p.color.model() == m).then_some(m)
}
/// Add a point; returns its index.
pub fn add_point(&mut self, p: FreeformPoint) -> usize {
self.points.push(p);
@@ -324,8 +352,9 @@ impl Freeform {
true
}
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the colour,
/// opacity and spread between the segment's ends. Returns the new point's index.
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the color
/// (in the model the ends share, [`Color::lerp_keep_model`]), opacity and spread between the
/// segment's ends. Returns the new point's index.
pub fn split_line(&mut self, line: usize, segment: usize, t: f64) -> Option<usize> {
let c = *self.segments(line).get(segment)?;
let l = &self.lines[line];
@@ -334,7 +363,7 @@ impl Freeform {
let u = t as f32;
let p = FreeformPoint {
at: c.eval(t),
color: a.color.lerp(&b.color, u),
color: a.color.lerp_keep_model(&b.color, u),
opacity: a.opacity + (b.opacity - a.opacity) * u,
spread: a.spread + (b.spread - a.spread) * u,
};
@@ -440,6 +469,41 @@ impl Field {
}
}
/// A freeform gradient ready to sample in its points' color model ([`Freeform::model_field`]).
#[derive(Clone, Debug)]
pub struct ModelField {
/// The model the points share (`None` when their models differ).
model: Option<Model>,
/// The first three components (C, M and Y; R, G and B; L, a and b; display RGB when the
/// models differ), or the Gray ink as the first of the three values.
first: Field,
/// The K ink of a CMYK field.
k: Option<Field>,
}
impl ModelField {
/// The model the points share (`None` when their models differ).
pub fn model(&self) -> Option<Model> {
self.model
}
/// The color and opacity at `p`: in the points' shared model, else in the working RGB space.
pub fn sample(&self, p: Point) -> (Color, f32) {
let ([x, y, z], a) = self.first.sample(p);
let c = match self.model {
None => {
let [r, g, b] = cms::active().srgb_to_rgb([x, y, z]);
Color::rgb(r, g, b)
}
Some(Model::Rgb) => Color::rgb(x, y, z),
Some(Model::Cmyk) => Color::cmyk(x, y, z, self.k.as_ref().map_or(0.0, |k| k.sample(p).0[0])),
Some(Model::Gray) => Color::gray(x),
Some(Model::Lab) => Color::lab(x, y, z),
};
(c, a)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -477,6 +541,31 @@ mod tests {
assert!(close(c.to_rgb(), [0.5, 0.0, 0.5]) && (o - 0.75).abs() < 1e-6, "{c:?}");
}
#[test]
fn the_sample_mixes_in_the_model_the_points_share() {
let halfway = |a: Color, b: Color| {
let mut f = two(0.0);
(f.points[0].color, f.points[1].color) = (a, b);
f.sample(Point::new(50.0, 0.0), 50.0).0
};
let near = |a: Color, b: Color| match (a, b) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => {
[c - c2, m - m2, y - y2, k - k2].iter().all(|d| d.abs() < 1e-5)
}
(Color::Gray { k }, Color::Gray { k: k2 }) => (k - k2).abs() < 1e-5,
(Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => [l - l2, a - a2, b - b2].iter().all(|d| d.abs() < 1e-4),
_ => false,
};
let c = halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0));
assert!(near(c, Color::cmyk(0.5, 0.0, 0.0, 0.5)), "{c:?}");
let c = halfway(Color::gray(0.2), Color::gray(0.6));
assert!(near(c, Color::gray(0.4)), "{c:?}");
let c = halfway(Color::lab(50.0, 10.0, -20.0), Color::lab(70.0, 30.0, 0.0));
assert!(near(c, Color::lab(60.0, 20.0, -10.0)), "{c:?}");
// Points in different models mix in RGB.
assert!(matches!(halfway(Color::cmyk(1.0, 0.0, 0.0, 0.0), red()), Color::Rgb { .. }));
}
#[test]
fn spread_keeps_the_colour_pure_inside_and_falls_off_outside() {
// Scale 100: a 0.2 spread is a 20 pt disc.
+11
View File
@@ -241,6 +241,17 @@ impl Color {
let b = other.to_rgb();
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
}
/// Linear interpolation in the model both colors share when it is CMYK, Gray or Lab, else in
/// display RGB ([`Color::lerp`]).
pub fn lerp_keep_model(&self, other: &Color, t: f32) -> Color {
let l = |x: f32, y: f32| x + (y - x) * t;
match (*self, *other) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
_ => self.lerp(other, t),
}
}
/// The colour model this colour is authored in.
pub fn model(&self) -> cms::Model {
match self {
+2
View File
@@ -14,6 +14,8 @@ vectorcraft-color = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
tiff = { workspace = true }
# 1-bit TIFFs compressed with CCITT Group 3 (Compression 2 and 3).
hayro-ccitt = { workspace = true }
zune-jpeg = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+1 -7
View File
@@ -33,13 +33,7 @@ use crate::text::TextObject;
/// Interpolate two colours in their shared model (display RGB when the models differ).
pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color {
let l = |x: f32, y: f32| x + (y - x) * t;
match (*a, *b) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
_ => a.lerp(b, t),
}
a.lerp_keep_model(b, t)
}
fn lerp_f32(x: f32, y: f32, t: f32) -> f32 {
+156
View File
@@ -349,6 +349,162 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3])
image::RgbaImage::from_raw(w, h, out)
}
/// Why a TIFF compressed with CCITT Group 3 wasn't read ([`group3_tiff_rgba`]).
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum Group3Error {
/// Its pixels would take more than [`crate::MAX_RASTER_ALLOC`] as RGBA.
#[error("an image of {w} × {h} pixels is too large to draw")]
TooLarge { w: u32, h: u32 },
/// A strip or tile ends before its last row or can't be decoded (it holds a code the decoder
/// doesn't read, such as T.4's uncompressed mode, or a row of the wrong width), or a tile is
/// larger than [`crate::MAX_RASTER_ALLOC`] allows as RGBA.
#[error("could not decode image: {0}")]
Damaged(hayro_ccitt::DecodeError),
}
/// The pixels of a 1-bit TIFF compressed with CCITT Group 3, which the image decoder doesn't read,
/// as RGBA. Compression 2 is modified Huffman coding with each row starting at a byte and no EOL
/// codes. Compression 3 is ITU-T T.4 coding with an EOL code before each row: one-dimensional rows,
/// or with T4Options bit 0, rows that a tag bit marks as one-dimensional or as coded against the
/// row above. Fill bits before an EOL are skipped. Rows in T.4's uncompressed mode (T4Options
/// bit 1) can't be decoded. Each strip or tile is coded on its own. White runs are 0 bits and black
/// runs are 1 bits, as libtiff writes them, and PhotometricInterpretation 0 shows 0 bits as white
/// and 1 shows them as black. FillOrder 2 (each byte's lowest bit first) is read too. Together, the
/// strips and tiles are read from at most as many bytes as the file has. `None` for other images;
/// an error when the pixels would take more than [`crate::MAX_RASTER_ALLOC`] as RGBA, a tile is
/// larger than that allowance, or a strip or tile can't be decoded or ends before its last row.
pub fn group3_tiff_rgba(bytes: &[u8]) -> Option<Result<image::RgbaImage, Group3Error>> {
use hayro_ccitt::{DecodeError, DecodeSettings, DecoderContext, EncodingMode};
use tiff::tags::Tag;
let (kind, mut d) = tiff(bytes)?;
let compression = d.find_tag_unsigned::<u16>(Tag::Compression).ok().flatten()?;
let (w, h) = d.dimensions().ok()?;
if kind != tiff::ColorType::Gray(1) || !matches!(compression, 2 | 3) || w == 0 || h == 0 {
return None;
}
// The level of a 0 bit (a white run): white when white is zero (0), black when black is zero (1).
let zero = match d.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten() {
Some(0) => u8::MAX,
Some(1) => 0,
_ => return None,
};
if u64::from(w) * u64::from(h) > crate::MAX_RASTER_ALLOC / 4 {
return Some(Err(Group3Error::TooLarge { w, h }));
}
let t4_options = d.find_tag_unsigned::<u32>(Tag::Unknown(292)).ok().flatten().unwrap_or(0);
let lsb_first = d.find_tag_unsigned::<u16>(Tag::FillOrder).ok().flatten() == Some(2);
let encoding = if compression == 3 && t4_options & 1 != 0 {
// Each row's tag bit says how it is coded.
EncodingMode::Group3_2D { k: 2 }
} else {
EncodingMode::Group3_1D
};
// The width and height of each strip or tile, and the tags of their offsets and byte counts.
let tiled = d.get_chunk_type() == tiff::decoder::ChunkType::Tile;
let (cw, ch, offsets, counts) = if tiled {
let tw = d.find_tag_unsigned::<u32>(Tag::TileWidth).ok().flatten().filter(|&v| v > 0)?;
let th = d.find_tag_unsigned::<u32>(Tag::TileLength).ok().flatten().filter(|&v| v > 0)?;
(tw, th, Tag::TileOffsets, Tag::TileByteCounts)
} else {
let rows = d.find_tag_unsigned::<u32>(Tag::RowsPerStrip).ok().flatten().filter(|&v| v > 0).map_or(h, |r| r.min(h));
(w, rows, Tag::StripOffsets, Tag::StripByteCounts)
};
let damaged = |e: DecodeError| Some(Err(Group3Error::Damaged(e)));
// A tile is held to the allowance the image is held to; a strip is no larger than the image.
if u64::from(cw) * u64::from(ch) > crate::MAX_RASTER_ALLOC / 4 {
return damaged(DecodeError::Overflow);
}
let offsets = d.find_tag_unsigned_vec::<u64>(offsets).ok().flatten().unwrap_or_default();
let counts = d.find_tag_unsigned_vec::<u64>(counts).ok().flatten().unwrap_or_default();
let across = w.div_ceil(cw);
let chunks = u64::from(across) * u64::from(h.div_ceil(ch));
if chunks > offsets.len().min(counts.len()) as u64 {
return damaged(DecodeError::UnexpectedEof);
}
let mut gray = vec![zero; usize::try_from(u64::from(w) * u64::from(h)).ok()?];
let mut budget = bytes.len();
for (i, (&offset, &count)) in (0..chunks).zip(offsets.iter().zip(&counts)) {
let (x0, y0) = (u32::try_from(i % u64::from(across)).ok()? * cw, u32::try_from(i / u64::from(across)).ok()? * ch);
let Some(start) = usize::try_from(offset).ok().filter(|&s| s <= bytes.len()) else {
return damaged(DecodeError::UnexpectedEof);
};
let len = usize::try_from(count).unwrap_or(usize::MAX).min(bytes.len() - start).min(budget);
budget -= len;
let data = bytes.get(start..start + len)?;
let data: std::borrow::Cow<[u8]> = if lsb_first { data.iter().map(|b| b.reverse_bits()).collect() } else { data.into() };
let settings = DecodeSettings {
columns: cw,
// A tile is coded whole; the last strip only has the image's remaining rows.
rows: if tiled { ch } else { ch.min(h - y0) },
end_of_block: compression == 3,
end_of_line: compression == 3,
rows_are_byte_aligned: compression == 2,
encoding,
invert_black: false,
};
let mut rows = Rows { out: &mut gray, width: w, height: h, x0, y0, x: 0, y: 0, zero };
if let Err(e) = hayro_ccitt::decode(&data, &mut rows, &mut DecoderContext::new(settings)) {
return damaged(e);
}
// The data ended, or six EOL codes came, before the strip's or tile's last row.
if rows.y < settings.rows {
return damaged(DecodeError::UnexpectedEof);
}
}
let gray = image::GrayImage::from_raw(w, h, gray)?;
Some(Ok(image::DynamicImage::ImageLuma8(gray).to_rgba8()))
}
/// Where a strip or tile of a Group 3 TIFF ([`group3_tiff_rgba`]) puts its decoded pixels: `out`
/// holds the image's levels row by row, `width` × `height`, and the strip or tile starts at (`x0`,
/// `y0`). Pixels past the image's right or bottom edge (a tile's padding) are dropped.
struct Rows<'a> {
out: &'a mut [u8],
width: u32,
height: u32,
x0: u32,
y0: u32,
/// The next pixel in the strip or tile.
x: u64,
y: u32,
/// The level of a white run.
zero: u8,
}
impl Rows<'_> {
/// The next `n` pixels of the row, a white run's or a black run's.
fn run(&mut self, white: bool, n: u64) {
let level = if white { self.zero } else { u8::MAX - self.zero };
let row = u64::from(self.y0) + u64::from(self.y);
let left = u64::from(self.x0) + self.x;
let right = left.saturating_add(n).min(u64::from(self.width));
if row < u64::from(self.height) && left < right {
let at = row * u64::from(self.width);
if let (Ok(a), Ok(b)) = (usize::try_from(at + left), usize::try_from(at + right))
&& let Some(px) = self.out.get_mut(a..b)
{
px.fill(level);
}
}
self.x = self.x.saturating_add(n);
}
}
impl hayro_ccitt::Decoder for Rows<'_> {
fn push_pixel(&mut self, white: bool) {
self.run(white, 1);
}
fn push_pixel_chunk(&mut self, white: bool, chunk_count: u32) {
self.run(white, u64::from(chunk_count) * 8);
}
fn next_line(&mut self) {
self.y = self.y.saturating_add(1);
self.x = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
+157 -17
View File
@@ -311,8 +311,7 @@ fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom
/// hidden, when it can't be edited (`editable_only`), when it is the part of a Shaper group that
/// isn't shown, or when `p` is outside its reach.
fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
let source_isolated = |n: &Node| n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == opt.scope);
if parent.extras().shaper.is_some() && (i == 0) != source_isolated(parent) {
if shaper_hides(parent, i, opt.scope) {
return false;
}
// Read-only samples can read locked/template art; hidden art never participates.
@@ -321,10 +320,22 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
}
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c)
|| c.extras().shaper.is_some() && source_isolated(c)
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
}
/// Is the first child of `n` the isolated container `scope`?
fn source_of(n: &Node, scope: Option<NodeId>) -> bool {
n.children().and_then(|children| children.first()).is_some_and(|source| Some(source.id) == scope)
}
/// Do clicks and marquees skip the child `i` of `parent` with `scope` isolated? For a Shaper
/// Group that is its original shapes (its first child), or, in Construction mode, when they are
/// isolated, the art the group shows.
fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
}
fn hit_children(
parent: &Node,
p: Point,
@@ -493,7 +504,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
Some(s) => match (s.children(), marquee_in(s, r)) {
(Some(c), Some(r)) => c.iter().map(|c| (c, r)).collect(),
(Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
(Some(_), None) => vec![],
// A single object isolated: only it.
(None, _) => doc
@@ -529,13 +540,17 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
out
}
fn touches(n: &Node, s: &SymbolBoxes, r: Rect) -> bool {
/// Does the marquee `r` touch `n`? It skips the children of a Shaper Group that clicks skip
/// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched
/// by the copies it draws, as clicks hit them. Objects of every other kind except paths,
/// compound paths, layers and groups are touched by their box.
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
match &n.kind {
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
marquee_in(n, r).is_some_and(|r| children.iter().any(|c| c.visible && touches(c, s, r)))
}
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r)),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r)
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
NodeKind::Repeat(spec) => spec.expand().iter().any(|copy| touches(copy, s, r, scope)),
// A box with no width or no height (an instance of a symbol that draws a straight
// line) is touched where the marquee crosses it.
_ => n.geometric_bounds_with(s).is_some_and(|b| {
@@ -544,18 +559,18 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}),
}
}
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, out: &mut Vec<NodeId>) {
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => {
let Some(r) = marquee_in(n, r) else { return };
for c in children {
if c.visible && !c.locked {
collect_leaves(c, s, r, out);
for (i, c) in children.iter().enumerate() {
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
collect_leaves(c, s, r, scope, out);
}
}
}
_ => {
if touches(n, s, r) {
if touches(n, s, r, scope) {
out.push(n.id);
}
}
@@ -567,8 +582,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
continue;
}
if leaves {
collect_leaves(t, &symbols, r, &mut out);
} else if touches(t, &symbols, r) {
collect_leaves(t, &symbols, r, scope, &mut out);
} else if touches(t, &symbols, r, scope) {
out.push(t.id);
}
}
@@ -771,6 +786,131 @@ mod tests {
}
}
/// A marquee touches a Shaper Group only by the art it shows, as clicks do ([`may_hit`]): over
/// the original shape of a face the group erased it selects nothing, with Group Selection's
/// leaves too, and over a face it shows it takes the group. With the original shapes isolated
/// (Construction mode) it takes the original shape it reaches.
#[test]
fn a_marquee_leaves_a_shaper_group_by_its_original_shapes() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// Two original squares, 0..100 and 50..150; the group shows only the first one's face.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
let b_id = b.id;
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
sources.name = Some(crate::shaper::SOURCES.into());
sources.opacity = 0.0;
let sources_id = sources.id;
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
let face_id = face.id;
let g = d.alloc_id();
let mut group = Node::group(g, vec![Arc::new(sources), Arc::new(face)]);
group.extras_mut().shaper = Some(Box::default());
d.insert(Some(l), 0, group).unwrap();
// This marquee crosses only the right edge of the second square, whose face is erased.
let erased = Rect::new(140.0, 40.0, 160.0, 60.0);
assert!(hit_test(&d, Point::new(150.0, 50.0), HitOptions::default()).is_none(), "a click there misses");
assert!(marquee(&d, erased, None, false).is_empty());
assert!(marquee(&d, erased, None, true).is_empty());
assert!(marquee(&d, erased, Some(g), false).is_empty());
// This marquee crosses the shown face's left edge.
let shown = Rect::new(-10.0, 40.0, 10.0, 60.0);
assert_eq!(marquee(&d, shown, None, false), vec![g]);
assert_eq!(marquee(&d, shown, None, true), vec![face_id]);
assert_eq!(marquee(&d, shown, Some(g), false), vec![face_id]);
// Construction mode: the original shapes are the objects.
assert_eq!(marquee(&d, erased, Some(sources_id), false), vec![b_id]);
}
/// A marquee touches an Intertwine group by its members, as it touches a plain group (#1103) and
/// as clicks do: in the gap between two members, or inside one member's fill, it selects nothing.
/// Across a member's edge it selects the group, and with the leaves that Direct and Group
/// Selection take, or in isolation mode, it selects the member.
#[test]
fn a_marquee_meets_an_intertwine_group_by_its_members() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// Two squares, 0..100 and 150..250, with a gap between them inside the group's box.
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
let mut a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
a.extras_mut().intertwine = Some(Box::new(shapes::rectangle(Rect::new(50.0, 0.0, 100.0, 100.0))));
let b = Node::path(d.alloc_id(), square(150.0), Appearance::default_art());
let (a_id, g) = (a.id, d.alloc_id());
d.insert(Some(l), 0, Node::new(g, NodeKind::Intertwine { children: vec![Arc::new(a), Arc::new(b)] })).unwrap();
let gap = Rect::new(110.0, 40.0, 140.0, 60.0);
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click in the gap misses");
assert!(marquee(&d, gap, None, false).is_empty());
assert!(marquee(&d, gap, None, true).is_empty());
assert!(marquee(&d, gap, Some(g), false).is_empty());
assert!(marquee(&d, Rect::new(20.0, 20.0, 40.0, 40.0), None, false).is_empty(), "inside a member's fill");
// This marquee crosses the first square's left edge.
let edge = Rect::new(-10.0, 40.0, 10.0, 60.0);
assert_eq!(marquee(&d, edge, None, false), vec![g]);
assert_eq!(marquee(&d, edge, None, true), vec![a_id]);
assert_eq!(marquee(&d, edge, Some(g), false), vec![a_id]);
}
/// A marquee touches a repeat by the copies it draws, as clicks do: between two copies of a
/// grid repeat it selects nothing, and across a copy's edge it takes the repeat.
#[test]
fn a_marquee_meets_a_repeat_by_its_copies() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let source = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
// Copies at 0..20, 40..60 and 80..100.
let kind = crate::pattern::RepeatKind::Grid {
h_spacing: 20.0,
v_spacing: 20.0,
rows: 1,
cols: 3,
grid_type: crate::pattern::TileType::Grid,
flip_rows: false,
flip_cols: false,
};
let r = d.alloc_id();
d.insert(Some(l), 0, Node::new(r, NodeKind::Repeat(crate::pattern::RepeatSpec { kind, source: vec![Arc::new(source)] }))).unwrap();
let gap = Rect::new(25.0, 5.0, 35.0, 15.0);
assert!(hit_test(&d, gap.center(), HitOptions::default()).is_none(), "a click between copies misses");
assert!(marquee(&d, gap, None, false).is_empty());
assert!(marquee(&d, gap, None, true).is_empty());
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, false), vec![r], "across the second copy's left edge");
assert_eq!(marquee(&d, Rect::new(35.0, 5.0, 45.0, 15.0), None, true), vec![r]);
}
/// A blend and an envelope are clicked anywhere in their box, and a marquee touches them there
/// too: inside the box, away from every key object's and the content's outline, it takes them.
#[test]
fn a_marquee_meets_blends_and_envelopes_by_their_box() {
let mut d = Document::new(300.0, 300.0);
let l = d.layers[0].id;
let square = |id, x: f64, y: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art()));
let blend = d.alloc_id();
let keys = vec![square(d.alloc_id(), 0.0, 0.0), square(d.alloc_id(), 100.0, 0.0)];
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Default::default() })).unwrap();
let envelope = d.alloc_id();
let content = vec![Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 150.0, 100.0, 250.0)), Appearance::default_art()))];
let e = Node::new(
envelope,
NodeKind::Envelope {
content,
kind: Box::new(crate::live::EnvelopeKind::Warp { style: "arc".to_string(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true }),
fidelity: 100.0,
editing: false,
options: Default::default(),
frame: Box::new(vectorcraft_geom::Affine::IDENTITY),
},
);
d.insert(Some(l), 1, e).unwrap();
for (r, id) in [(Rect::new(50.0, 5.0, 60.0, 15.0), blend), (Rect::new(40.0, 190.0, 60.0, 210.0), envelope)] {
assert_eq!(hit_test(&d, r.center(), HitOptions::default()).map(|h| h.leaf), Some(id));
assert_eq!(marquee(&d, r, None, false), vec![id]);
assert_eq!(marquee(&d, r, None, true), vec![id]);
}
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
+3 -3
View File
@@ -117,9 +117,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics,
SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
+38 -7
View File
@@ -730,19 +730,35 @@ impl GradientMesh {
.to_point()
}
/// Bilinear colour and opacity of patch (r, c) at local (u, v).
/// Bilinear colour and opacity of patch (r, c) at local (u, v). Corners all in CMYK (or all
/// grey) mix in that space, as a PDF shading mixes in its own colour space: the inks come out
/// exact, and a CMYK document separates no colour to draw them. Other corners mix in RGB.
pub fn color_at(&self, r: usize, c: usize, u: f64, v: f64) -> (Color, f32) {
let q =
[&self.points[self.idx(r, c)], &self.points[self.idx(r, c + 1)], &self.points[self.idx(r + 1, c)], &self.points[self.idx(r + 1, c + 1)]];
let w = [(1.0 - u) * (1.0 - v), u * (1.0 - v), (1.0 - u) * v, u * v];
let op = q.iter().zip(w).map(|(p, w)| p.opacity * w as f32).sum();
let cmyk: Option<Vec<[f32; 4]>> =
q.iter().map(|p| if let Color::Cmyk { c, m, y, k } = p.color { Some([c, m, y, k]) } else { None }).collect();
if let Some(inks) = cmyk {
let mut mix = [0.0f32; 4];
for (ink, w) in inks.iter().zip(w) {
for (m, x) in mix.iter_mut().zip(ink) {
*m += x * w as f32;
}
}
let [c, m, y, k] = mix;
return (Color::Cmyk { c, m, y, k }, op);
}
let grey: Option<Vec<f32>> = q.iter().map(|p| if let Color::Gray { k } = p.color { Some(k) } else { None }).collect();
if let Some(ks) = grey {
return (Color::Gray { k: ks.iter().zip(w).map(|(k, w)| k * w as f32).sum() }, op);
}
let mut rgb = [0.0f32; 3];
let mut op = 0.0f32;
for k in 0..4 {
let c = q[k].color.to_rgb();
for (ch, x) in rgb.iter_mut().zip(c) {
*ch += x * w[k] as f32;
for (p, w) in q.iter().zip(w) {
for (ch, x) in rgb.iter_mut().zip(p.color.to_rgb()) {
*ch += x * w as f32;
}
op += q[k].opacity * w[k] as f32;
}
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
}
@@ -1823,4 +1839,19 @@ mod tests {
assert!(m.remove_point_lines(i));
assert_eq!((m.rows, m.cols), (1, 1));
}
/// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB.
#[test]
fn a_cmyk_mesh_mixes_its_inks() {
let cyan = Color::Cmyk { c: 1.0, m: 0.0, y: 0.0, k: 0.0 };
let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, cyan, MeshAppearance::Flat, 0.0).unwrap();
m.points[0].color = Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 };
let (c, op) = m.color_at(0, 0, 0.0, 0.0);
assert_eq!((c, op), (Color::Cmyk { c: 0.0, m: 0.0, y: 0.0, k: 1.0 }, 1.0));
let Color::Cmyk { c, k, .. } = m.color_at(0, 0, 0.5, 0.5).0 else { panic!("mixed out of CMYK") };
assert!((c - 0.75).abs() < 1e-6 && (k - 0.25).abs() < 1e-6, "{c} {k}");
// A corner in another space mixes them all in RGB.
m.points[1].color = Color::rgb(1.0, 0.0, 0.0);
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
}
}
+1 -1
View File
@@ -430,7 +430,7 @@ fn art_id() -> crate::NodeId {
}
/// Visit `n` and every descendant (opacity-mask art included) mutably.
fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
pub(crate) fn each_mut(n: &mut Node, f: &mut impl FnMut(&mut Node)) {
f(n);
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
each_mut(Arc::make_mut(&mut m.art), f);
+328
View File
@@ -179,6 +179,18 @@ pub struct ScriptMetrics {
impl ScriptMetrics {
pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 };
/// Returns these proportions with the size kept to 1 to 500% and the position kept to -500 to
/// 500%, the ranges `document.setup` takes for `superscript` and `subscript`. A value outside
/// its range becomes the nearer end.
pub fn in_range(self) -> Self {
Self { size: self.size.clamp(1.0, 500.0), position: self.position.clamp(-500.0, 500.0) }
}
}
/// Returns a small caps size kept to 1 to 200%, the range `document.setup` takes for
/// `smallCapsSize`. A size outside it becomes the nearer end.
fn small_caps_in_range(size: f64) -> f64 {
size.clamp(1.0, 200.0)
}
impl Default for ScriptMetrics {
@@ -215,6 +227,14 @@ impl CharPosition {
CharPosition::Subscript(_) => "subscript",
}
}
/// Returns this position with its proportions kept by [`ScriptMetrics::in_range`].
pub fn in_range(self) -> Self {
match self {
CharPosition::Normal => CharPosition::Normal,
CharPosition::Superscript(m) => CharPosition::Superscript(m.in_range()),
CharPosition::Subscript(m) => CharPosition::Subscript(m.in_range()),
}
}
}
fn ten() -> f64 {
@@ -273,6 +293,20 @@ impl CharStyle {
pub fn effective_leading(&self) -> f64 {
self.leading.unwrap_or(self.size * 1.2)
}
/// Keeps the size, leading, baseline shift and horizontal and vertical scales in range: a size
/// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading
/// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way)
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
/// baseline shift that is not a number becomes 0.
pub fn keep_dimensions_in_range(&mut self) {
let default = Self::default();
let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or };
self.size = within(self.size, default.size);
self.h_scale = within(self.h_scale, default.h_scale);
self.v_scale = within(self.v_scale, default.v_scale);
self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD));
self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) };
}
/// Do the characters draw a stroke (a paint and a positive weight)?
pub fn has_stroke(&self) -> bool {
!self.stroke.is_none() && self.stroke_width > 0.0
@@ -333,6 +367,60 @@ impl CharStyle {
pub fn basic_appearance(&self) -> Appearance {
Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() }
}
/// Keeps the superscript or subscript proportions in range with [`CharPosition::in_range`] and
/// keeps the small caps size to 1 to 200%, the range `document.setup` takes for `smallCapsSize`.
pub fn keep_scripts_in_range(&mut self) {
self.position = self.position.in_range();
self.small_caps = self.small_caps.map(small_caps_in_range);
}
}
impl crate::Document {
/// Keeps the superscript, subscript and small caps proportions to the ranges `document.setup`
/// takes ([`CharStyle::keep_scripts_in_range`]) in the type of the layers, symbols, graph
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
/// back (opacity-mask art included), in graphs' label styles and in the character styles. It
/// recurses through the art: call it on a document no deeper than [`crate::depth::MAX_DEPTH`].
pub fn keep_scripts_in_range(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
r.style.keep_scripts_in_range();
}
}
if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
let l = &mut g.label_styles;
for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() {
st.keep_scripts_in_range();
}
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
// Character styles store the attributes they set by their serialized names.
for def in self.char_styles.iter_mut() {
if let Some(v) = def.attrs.get_mut("position")
&& let Ok(p) = serde_json::from_value::<CharPosition>(v.clone())
&& p.in_range() != p
{
*v = serde_json::to_value(p.in_range()).unwrap_or(serde_json::Value::Null);
}
if let Some(v) = def.attrs.get_mut("small_caps")
&& let Some(size) = v.as_f64()
&& small_caps_in_range(size) != size
{
*v = small_caps_in_range(size).into();
}
}
}
}
/// Tab stop alignment (Tabs panel).
@@ -418,6 +506,98 @@ impl Default for HyphenationOptions {
}
}
/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SingleWordJustify {
/// Spread across the line, between its letters.
#[default]
FullJustify,
AlignLeft,
AlignCenter,
AlignRight,
}
/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent.
///
/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired
/// applies to every line of the paragraph, justified or not: word spaces are that share of the
/// font's word space, each letter is followed by that share of a word space, and glyphs are that
/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only
/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph
/// scaling 100/100/100, Auto Leading 120 and Full Justify.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct JustificationOptions {
/// Word Spacing, percent of the font's word space (0..1000).
pub word_spacing: [f64; 3],
/// Letter Spacing, percent of the font's word space added after each letter (-100..500).
pub letter_spacing: [f64; 3],
/// Glyph Scaling, percent of the glyphs' width (50..200).
pub glyph_scaling: [f64; 3],
/// Auto Leading: the leading of characters whose leading is Auto, in percent of their size
/// (0..500).
pub auto_leading: f64,
/// Single Word Justification: how a justified line holding one word is set.
pub single_word: SingleWordJustify,
}
impl JustificationOptions {
/// Word Spacing's range (percent).
pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0);
/// Letter Spacing's range (percent).
pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0);
/// Glyph Scaling's range (percent).
pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0);
/// Auto Leading's range (percent).
pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0);
/// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change
/// in a line's natural widths)?
pub fn desired_is_default(&self) -> bool {
let d = Self::default();
self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1]
}
/// Keeps options read from a file to what `text.setFormat` can set: a value that is not a
/// finite number becomes its default, every value is clamped to its range, and a Minimum,
/// Desired and Maximum out of order go back to their defaults together.
pub fn keep_in_range(&mut self) {
let d = Self::default();
for (v, dv, (lo, hi)) in [
(&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING),
(&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING),
(&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING),
] {
for (x, dx) in v.iter_mut().zip(dv) {
*x = if x.is_finite() { x.clamp(lo, hi) } else { dx };
}
if !(v[0] <= v[1] && v[1] <= v[2]) {
*v = dv;
}
}
let (lo, hi) = Self::AUTO_LEADING;
self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading };
}
/// A paragraph's leading for characters of `size` whose leading is Auto.
pub fn auto_leading_for(&self, size: f64) -> f64 {
size * self.auto_leading / 100.0
}
}
impl Default for JustificationOptions {
fn default() -> Self {
Self {
word_spacing: [80.0, 100.0, 133.0],
letter_spacing: [0.0, 0.0, 0.0],
glyph_scaling: [100.0, 100.0, 100.0],
auto_leading: 120.0,
single_word: SingleWordJustify::FullJustify,
}
}
}
/// Paragraph attributes (the Paragraph panel).
///
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
@@ -434,6 +614,9 @@ pub struct ParaStyle {
pub hyphenate: bool,
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
pub hyphenation: HyphenationOptions,
/// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification
/// (Paragraph panel menu › Justification…).
pub justification: JustificationOptions,
/// Line breaking: Single-line or Every-line Composer.
pub composer: Composer,
/// Tab stops (Tabs panel), sorted by position.
@@ -458,6 +641,26 @@ pub struct ParaStyle {
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
/// line.
pub kinsoku: Kinsoku,
/// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
pub roman_hanging: bool,
}
impl ParaStyle {
/// Keeps the indents and the space before and after to -[`crate::MAX_COORD`] to
/// [`crate::MAX_COORD`], and the tab stop positions to 0 to 100,000 pt, the range
/// `text.tabs.set` takes. A value outside its range becomes the nearer end, and one that is
/// not a number becomes 0.
pub fn keep_spacing_in_range(&mut self) {
let within = |v: f64, lo: f64, hi: f64| if v.is_nan() { 0.0 } else { v.clamp(lo, hi) };
for v in [&mut self.left_indent, &mut self.right_indent, &mut self.first_line_indent, &mut self.space_before, &mut self.space_after] {
*v = within(*v, -crate::MAX_COORD, crate::MAX_COORD);
}
for t in &mut self.tabs {
t.position = within(t.position, 0.0, 100_000.0);
}
}
}
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
@@ -483,6 +686,8 @@ struct ParaStyleFile {
hyphenate: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
hyphenation: HyphenationOptions,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
justification: JustificationOptions,
#[serde(default, skip_serializing_if = "Composer::is_default")]
composer: Composer,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -499,6 +704,8 @@ struct ParaStyleFile {
burasagari: Burasagari,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kinsoku: Kinsoku,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
roman_hanging: bool,
}
impl From<ParaStyle> for ParaStyleFile {
@@ -517,6 +724,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_after,
hyphenate,
hyphenation,
justification,
composer,
tabs,
style_name,
@@ -525,6 +733,7 @@ impl From<ParaStyle> for ParaStyleFile {
leading_model,
burasagari,
kinsoku,
roman_hanging,
..
} = p;
Self {
@@ -537,6 +746,7 @@ impl From<ParaStyle> for ParaStyleFile {
space_after,
hyphenate,
hyphenation,
justification,
composer,
tabs,
style_name,
@@ -545,6 +755,7 @@ impl From<ParaStyle> for ParaStyleFile {
leading_model,
burasagari,
kinsoku,
roman_hanging,
}
}
}
@@ -552,6 +763,8 @@ impl From<ParaStyle> for ParaStyleFile {
impl From<ParaStyleFile> for ParaStyle {
fn from(f: ParaStyleFile) -> Self {
let justify = if f.justify_auto { Justify::Auto } else { f.justify };
let mut justification = f.justification;
justification.keep_in_range();
let ParaStyleFile {
left_indent,
right_indent,
@@ -568,6 +781,7 @@ impl From<ParaStyleFile> for ParaStyle {
leading_model,
burasagari,
kinsoku,
roman_hanging,
..
} = f;
Self {
@@ -579,6 +793,7 @@ impl From<ParaStyleFile> for ParaStyle {
space_after,
hyphenate,
hyphenation,
justification,
composer,
tabs,
style_name,
@@ -587,6 +802,7 @@ impl From<ParaStyleFile> for ParaStyle {
leading_model,
burasagari,
kinsoku,
roman_hanging,
}
}
}
@@ -1361,6 +1577,87 @@ impl TextObject {
});
self.cached_baselines.iter().copied().chain(first)
}
/// Keeps every run's size, leading, baseline shift and scales in range
/// ([`CharStyle::keep_dimensions_in_range`]) and every paragraph's indents, spacing and tab
/// stops ([`ParaStyle::keep_spacing_in_range`]).
pub fn keep_type_values_in_range(&mut self) {
for r in &mut self.runs {
r.style.keep_dimensions_in_range();
}
for p in self.para_styles_mut() {
p.keep_spacing_in_range();
}
}
}
impl crate::Document {
/// Keeps the character dimensions, indents, paragraph spacing and tab stops in range
/// ([`TextObject::keep_type_values_in_range`]) in the type of the layers, symbols, graph
/// designs, pattern swatches and the pattern definition that pattern editing's Cancel puts
/// back (opacity-mask art included), and the same attributes of the character and paragraph
/// styles. It recurses through the art: call it on a document no deeper than
/// [`crate::depth::MAX_DEPTH`].
pub fn keep_type_values_in_range(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
t.keep_type_values_in_range();
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
// Styles store the attributes they set by their serialized names (a number, or null for
// Auto leading); a number is rewritten only when it changes.
for def in self.char_styles.iter_mut() {
let num = |k: &str| def.attrs.get(k).and_then(serde_json::Value::as_f64);
let mut st = CharStyle { leading: num("leading"), ..CharStyle::default() };
st.size = num("size").unwrap_or(st.size);
st.h_scale = num("h_scale").unwrap_or(st.h_scale);
st.v_scale = num("v_scale").unwrap_or(st.v_scale);
st.baseline_shift = num("baseline_shift").unwrap_or(st.baseline_shift);
st.keep_dimensions_in_range();
let kept = [
("size", Some(st.size)),
("h_scale", Some(st.h_scale)),
("v_scale", Some(st.v_scale)),
("baseline_shift", Some(st.baseline_shift)),
("leading", st.leading),
];
for (k, v) in kept {
if let Some(old) = def.attrs.get_mut(k)
&& old.is_number()
&& old.as_f64() != v
{
*old = v.into();
}
}
}
for def in self.para_styles.iter_mut() {
for k in ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"] {
if let Some(v) = def.attrs.get_mut(k)
&& let Some(x) = v.as_f64()
&& x.abs() > crate::MAX_COORD
{
*v = x.clamp(-crate::MAX_COORD, crate::MAX_COORD).into();
}
}
for t in def.attrs.get_mut("tabs").and_then(serde_json::Value::as_array_mut).into_iter().flatten() {
if let Some(v) = t.get_mut("position")
&& let Some(x) = v.as_f64()
&& !(0.0..=100_000.0).contains(&x)
{
*v = x.clamp(0.0, 100_000.0).into();
}
}
}
}
}
#[cfg(test)]
@@ -1390,6 +1687,37 @@ mod tests {
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
/// A file can't feed layout justification values `text.setFormat` would refuse: each is
/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and
/// Maximum out of order go back to their defaults.
#[test]
fn justification_options_read_from_a_file_stay_in_range() {
let d = JustificationOptions::default();
let read: ParaStyle = serde_json::from_value(serde_json::json!({
"justification": {
"word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0],
"glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12
}
}))
.unwrap();
let j = read.justification;
assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]);
assert_eq!(j.letter_spacing, d.letter_spacing, "out of order");
assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]);
assert_eq!(j.auto_leading, 0.0);
let mut j = JustificationOptions {
word_spacing: [f64::NAN, 100.0, f64::INFINITY],
letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY],
auto_leading: f64::NAN,
..d.clone()
};
j.keep_in_range();
assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading));
let mut same = d.clone();
same.keep_in_range();
assert_eq!(same, d);
}
#[test]
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
+19 -5
View File
@@ -48,15 +48,29 @@ enum Channel {
impl Channel {
fn of(p: &Value) -> Self {
match text(p, "channel", "rgb").to_ascii_lowercase().as_str() {
"red" | "r" => Channel::One(0),
"green" | "g" => Channel::One(1),
"blue" | "b" => Channel::One(2),
_ => Channel::All,
Self::parse(text(p, "channel", "rgb")).unwrap_or(Channel::All)
}
fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"rgb" => Some(Channel::All),
"red" | "r" => Some(Channel::One(0)),
"green" | "g" => Some(Channel::One(1)),
"blue" | "b" => Some(Channel::One(2)),
_ => None,
}
}
}
/// The channels Curves and Levels apply to (`channel`), as their parameters list them.
pub const CHANNELS: [&str; 4] = ["rgb", "red", "green", "blue"];
/// Do Curves and Levels read `s` as their `channel`: one of [`CHANNELS`], or r, g or b, in any
/// case? They draw any other value on all three channels, as RGB.
pub fn is_channel(s: &str) -> bool {
Channel::parse(s).is_some()
}
/// Entries of a sampled tone curve.
const LUT: usize = 256;
+13 -9
View File
@@ -37,6 +37,7 @@ mod bake;
mod clip;
mod distort;
mod group;
mod lighting;
mod live;
mod marks;
pub mod pixel;
@@ -58,7 +59,10 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
pub use adjust::{
ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment,
is_channel,
};
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use group::{
@@ -203,29 +207,29 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
REVOLVE,
"Revolve…",
&["Effect", "3D and Materials"],
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
),
g(
EXTRUDE,
"Extrude & Bevel…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
INFLATE,
"Inflate…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
ROTATE,
"Rotate…",
&["Effect", "3D and Materials"],
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
"convertToShape.rectangle",
+373
View File
@@ -0,0 +1,373 @@
//! Shared conversion and vector paint shading for the 3D effects' directional lights.
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, FillLayer};
fn parse(v: &Value) -> Option<Color> {
let s = v.as_str()?;
// Match the command's opaque RGB contract, before any parsing or allocation.
(s.len() == 7 && s.starts_with('#') && s.as_bytes().iter().skip(1).all(u8::is_ascii_hexdigit)).then(|| Color::from_hex(s)).flatten()
}
pub(crate) fn light_color(p: &Value) -> [f64; 3] {
p.get("lightColor").and_then(parse).unwrap_or(Color::WHITE).to_rgb_uncalibrated().map(f64::from)
}
pub(crate) fn validate(p: &Value) -> Result<(), String> {
if p.get("lightColor").is_some_and(|v| parse(v).is_none()) {
return Err("lightColor must be an opaque RGB color in #rrggbb form".into());
}
if let Some(list) = p.get("lights").filter(|v| !v.is_null()) {
parse_lights(list)?;
}
Ok(())
}
pub(crate) fn lights(p: &Value) -> Option<vectorcraft_three_d::Lights> {
p.get("lights").filter(|v| !v.is_null()).and_then(|v| parse_lights(v).ok())
}
fn parse_lights(v: &Value) -> Result<vectorcraft_three_d::Lights, String> {
use vectorcraft_three_d::{Light, Lights, MAX_LIGHTS};
let list = v.as_array().filter(|v| !v.is_empty() && v.len() <= MAX_LIGHTS).ok_or("lights must contain one to ten lights")?;
let mut out = Vec::with_capacity(list.len());
for item in list {
let item = item.as_object().ok_or("each light must be an object")?;
if item.keys().any(|key| !["color", "intensity", "rotation", "height", "softness", "behind"].contains(&key.as_str())) {
return Err("unknown light setting: use color, intensity, rotation, height, softness or behind".into());
}
let defaults = Light::default();
let number = |key: &str, default: f64, min: f64, max: f64| -> Result<f64, String> {
match item.get(key) {
None => Ok(default),
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && *n >= min && *n <= max)
.ok_or_else(|| format!("light {key} must be a number from {min} to {max}")),
}
};
let color = match item.get("color") {
None => [1.0; 3],
Some(v) => parse(v).ok_or("light color must use opaque #rrggbb")?.to_rgb_uncalibrated().map(f64::from),
};
let behind = item.get("behind").map_or(Ok(false), |v| v.as_bool().ok_or("light behind must be true or false"))?;
out.push(Light {
color,
behind,
intensity: number("intensity", defaults.intensity * 100.0, 0.0, 100.0)? / 100.0,
rotation: number("rotation", defaults.rotation, -180.0, 180.0)?,
height: number("height", defaults.height, 0.0, 90.0)?,
softness: number("softness", defaults.softness * 100.0, 0.0, 100.0)? / 100.0,
});
}
Lights::new(&out)
}
pub(crate) fn shade(c: Color, rgb: [f64; 3]) -> Color {
let [r, g, b] = c.to_rgb();
Color::rgb(r * rgb[0] as f32, g * rgb[1] as f32, b * rgb[2] as f32)
}
pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint {
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect();
let mut p = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None });
Paint::Gradient(Box::new(p))
}
/// Multiply two independently placed vector gradients before applying the face's coverage
/// once. Separately antialiasing the source and light along each triangle edge leaves seams.
pub(crate) fn gradient_art(
id: vectorcraft_doc::NodeId,
path: &vectorcraft_geom::PathData,
paint: &Paint,
light: vectorcraft_three_d::LightGradient,
) -> vectorcraft_doc::Node {
composed_art(id, path, paint, Ramp::Single(light), 0.75)
}
pub(crate) fn rgb_gradient_art(
id: vectorcraft_doc::NodeId,
path: &vectorcraft_geom::PathData,
paint: &Paint,
light: vectorcraft_three_d::RgbGradient,
) -> vectorcraft_doc::Node {
composed_art(id, path, paint, Ramp::Rgb(light), 0.75)
}
/// Smooth Inflate triangles keep the seam within their narrow projected footprint.
pub(crate) fn facet_art(
id: vectorcraft_doc::NodeId,
path: &vectorcraft_geom::PathData,
paint: &Paint,
light: vectorcraft_three_d::RgbGradient,
seam: f64,
) -> vectorcraft_doc::Node {
let [r, g, b] = light.channels;
if r == g && g == b {
let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] };
if let Some(color) = paint.color() {
let paint = smooth_paint(light, color);
let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam);
edge.join = vectorcraft_doc::LineJoin::Round;
return vectorcraft_doc::Node::path(
id,
path.clone(),
vectorcraft_doc::Appearance {
items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)],
..Default::default()
},
);
}
return composed_art(id, path, paint, Ramp::Single(light), seam);
}
composed_art(id, path, paint, Ramp::Rgb(light), seam)
}
/// Composite smooth solid-fill facets with one clip and three channel groups per surface.
/// Black channel backdrops keep shared edges opaque before the final coverage is applied.
pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node {
use kurbo::Shape;
use std::sync::Arc;
use vectorcraft_doc::color::BlendMode;
use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, PathData, Rect, shapes};
let mut boundary = BezPath::new();
let mut bounds = None;
for face in facets {
let path = face.path();
if let Some(b) = path.bounds() {
bounds = Some(bounds.map_or(b, |r: Rect| r.union(b)));
}
let mut contour = path.to_bezpath();
if contour.area() < 0.0 {
contour = contour.reverse_subpaths();
}
boundary.extend(contour.iter());
}
let clip = Node::path(id, PathData::from_bezpath(&boundary), Appearance::default());
let bounds = bounds.unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
let channels = (0..3)
.map(|channel| {
let mut color = [0.0; 3];
if let Some(value) = color.get_mut(channel) {
*value = 1.0;
}
let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))];
children.extend(facets.iter().map(|face| {
let paint = if let Some(ramp) = face.rgb_gradient {
smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE)
} else {
Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel])))
};
let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
edge.join = vectorcraft_doc::LineJoin::Round;
Arc::new(Node::path(
id,
face.path(),
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() },
))
}));
let mut group = Node::group(id, children);
group.isolate = true;
group.blend = BlendMode::Screen;
Arc::new(group)
})
.collect();
let mut light = Node::group(id, channels);
light.isolate = true;
light.blend = BlendMode::Multiply;
let mut colors = Node::group(id, vec![Arc::new(cover(paint.clone())), Arc::new(light)]);
colors.isolate = true;
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true })
}
enum Ramp {
Single(vectorcraft_three_d::LightGradient),
Rgb(vectorcraft_three_d::RgbGradient),
}
/// Separate a gradient's color and coverage so a tessellated surface can apply its opacity
/// once, instead of building opacity at the overlapping seam strokes of adjacent faces.
pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option<Paint>) {
let mut base = paint.clone();
let mut mask = paint.clone();
let mut transparent = false;
if let (Paint::Gradient(b), Paint::Gradient(m)) = (&mut base, &mut mask) {
b.swatch = None;
m.swatch = None;
for (b, m) in b.gradient.stops.iter_mut().zip(&mut m.gradient.stops) {
transparent |= b.opacity != 1.0;
b.opacity = 1.0;
m.color = Color::WHITE;
m.swatch = None;
m.tint = 1.0;
}
if let (Some(b), Some(m)) = (&mut b.freeform, &mut m.freeform) {
for (b, m) in b.points.iter_mut().zip(&mut m.points) {
transparent |= b.opacity != 1.0;
b.opacity = 1.0;
m.color = Color::WHITE;
}
}
}
(base, transparent.then_some(mask))
}
fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node {
use kurbo::Shape;
use std::sync::Arc;
use vectorcraft_doc::color::BlendMode;
use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask};
use vectorcraft_geom::{Rect, shapes};
let (base, mask) = opaque_paint(paint);
// Clip once to the union of this single-contour face and its round seam stroke.
// Align their winding so the nonzero fill adds coverage instead of cancelling it.
let mut boundary = path.to_bezpath();
let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05);
if boundary.area() * footprint.area() < 0.0 {
boundary = boundary.reverse_subpaths();
}
footprint.extend(boundary.iter());
let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default());
// Opaque rectangles cover the complete seam stroke, without separately antialiasing
// either ramp. Opaque sources need only vector clipping, not full-frame opacity masks.
let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
let mut illumination = match light {
Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)),
Ramp::Rgb(light) => {
let channels = light
.channels
.iter()
.enumerate()
.map(|(channel, gradient)| {
let mut color = [0.0; 3];
if let Some(value) = color.get_mut(channel) {
*value = 1.0;
}
let mut layer = cover(smooth_paint(vectorcraft_three_d::LightGradient { diffuse: *gradient, ambient: 0.0, color }, Color::WHITE));
// Screen adds disjoint R, G and B channels exactly; no light is clamped
// separately. Each ramp already holds the sum of all directional lights.
layer.blend = BlendMode::Screen;
Arc::new(layer)
})
.collect();
let mut group = Node::group(id, channels);
group.isolate = true;
group
}
};
illumination.blend = BlendMode::Multiply;
let mut colors = Node::group(id, vec![Arc::new(cover(base)), Arc::new(illumination)]);
colors.isolate = true;
// Keep isolation inside the clip: the clip then applies to the finished colors,
// rather than separately antialiasing each of the two gradient drawings.
let mut face = Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true });
if let Some(mask) = mask {
face.extras_mut().mask = Some(Box::new(OpacityMask::new(cover(mask), true)));
}
face
}
pub(crate) fn fills(paint: &Paint, rgb: [f64; 3], brightness: f64, colored: bool) -> Vec<AppearanceItem> {
let shade = |c: Color| shade(c, rgb);
if let Some(c) = paint.color() {
return vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(shade(c))))];
}
// Keep the white-light gradient treatment identical to existing documents. Colored light
// shades the ramp itself, keeping placement, stops, midpoint and opacity as vector data.
if matches!(paint, Paint::Gradient(_)) && colored {
let mut shaded = paint.clone();
vectorcraft_doc::recolor::map_paint(&mut shaded, &shade);
if let Paint::Gradient(g) = &mut shaded {
g.swatch = None;
}
return vec![AppearanceItem::Fill(FillLayer::new(shaded))];
}
// Pattern tiles remain referenced artwork. Retain their existing neutral shadow
// approximation; tinting arbitrary pattern contents needs document-level evaluation.
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
shadow.opacity = (1.0 - brightness) as f32;
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Fill(shadow)]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
use vectorcraft_geom::Point;
#[test]
fn command_contract_is_strict_and_loaded_invalid_colors_fall_back_to_white() {
assert_eq!(light_color(&json!({})), [1.0; 3]);
assert_eq!(light_color(&json!({"lightColor":"#FF0000"})), [1.0, 0.0, 0.0]);
for v in [json!("#ff0"), json!("#ffffff80"), json!("blue"), json!("#gg0000"), json!(null), json!([1, 0, 0])] {
let p = json!({"lightColor":v});
assert!(validate(&p).is_err(), "{p}");
assert_eq!(light_color(&p), [1.0; 3]);
}
}
#[test]
fn malformed_loaded_lists_keep_the_legacy_light_and_valid_lists_override_it() {
for value in [json!([]), json!(vec![json!({}); 11]), json!([false]), json!([{"height": 91}]), json!([{"color":"#ff0"}])] {
let p = json!({"lights": value, "lightColor":"#ff0000"});
assert!(lights(&p).is_none());
assert_eq!(light_color(&p), [1.0, 0.0, 0.0]);
assert!(validate(&p).is_err());
}
let p = json!({"lights":[{}, {"color":"#0000ff","intensity":40}], "lightColor":"#ff0000"});
let list = lights(&p).unwrap();
assert_eq!(list.iter().count(), 2);
assert_eq!(*list.iter().next().unwrap(), vectorcraft_three_d::Light::default());
assert_eq!(list.iter().nth(1).unwrap().color, [0.0, 0.0, 1.0]);
}
#[test]
fn colored_gradient_keeps_placement_alpha_and_midpoints() {
let mut stop = GradientStop::new(0.0, Color::WHITE);
stop.opacity = 0.4;
stop.midpoint = 0.3;
let g = GradientPaint {
gradient: Gradient { kind: GradientKind::Linear, stops: vec![stop, GradientStop::new(1.0, Color::BLACK)] },
geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }),
angle: 0.0,
swatch: None,
freeform: None,
};
let paint = Paint::Gradient(Box::new(g.clone()));
let items = fills(&paint, [1.0, 0.25, 0.25], 1.0, true);
let Paint::Gradient(shaded) = items[0].paint() else { panic!("vector gradient") };
assert_eq!(shaded.geom, g.geom);
assert_eq!(shaded.gradient.stops[0].color.to_rgb(), [1.0, 0.25, 0.25]);
assert_eq!(shaded.gradient.stops[0].opacity, 0.4);
assert_eq!(shaded.gradient.stops[0].midpoint, 0.3);
assert_eq!(shaded.gradient.stops[1].color, Color::BLACK);
assert_eq!(paint, Paint::Gradient(Box::new(g)));
let white = fills(&paint, [0.4; 3], 0.4, false);
assert_eq!(white[0].paint(), &paint, "white retains the previous gradient treatment");
assert_eq!(white.len(), 2);
let unlit = fills(&paint, [0.25; 3], 0.25, true);
assert_eq!(unlit.len(), 1, "neutral ambient under colored light still tints once");
let mut freeform = if let Paint::Gradient(g) = paint { g } else { panic!("gradient") };
freeform.gradient.kind = GradientKind::Freeform;
freeform.gradient.stops = vec![GradientStop::new(0.0, Color::BLACK)];
freeform.swatch = Some("Original ramp".into());
freeform.freeform = Some(vectorcraft_doc::color::Freeform {
points: vec![vectorcraft_doc::color::FreeformPoint::new(Point::new(12.0, 18.0), Color::WHITE)],
..Default::default()
});
let original = Paint::Gradient(freeform);
let items = fills(&original, [1.0, 0.25, 0.25], 1.0, true);
let Paint::Gradient(shaded) = items[0].paint() else { panic!("freeform gradient") };
let point = shaded.freeform.as_ref().unwrap().points[0];
assert_eq!(point.at, Point::new(12.0, 18.0));
assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]);
assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch");
}
}
+3 -3
View File
@@ -165,7 +165,7 @@ fn planes(px: &[[u8; 4]]) -> ([Vec<f32>; 3], Vec<f32>) {
/// colours keep their hue. Slivers (the blends along softened edges) take the nearest large
/// piece's colour, so the pieces meet with crisp edges. Coverage is kept.
pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, levels: f64, simplicity: f64, fidelity: f64) {
if ![levels, simplicity, fidelity].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
if !([levels, simplicity, fidelity].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let levels = levels.round().clamp(2.0, 8.0) as usize;
@@ -212,7 +212,7 @@ pub(super) fn cutout(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace,
/// channel, with black lines drawn on the edges of any of its colour channels and of its outline. `thickness` (0..10) widens the lines and
/// `intensity` (0..10) darkens them and lets fainter edges take a line. Coverage is kept.
pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, thickness: f64, intensity: f64, posterization: f64) {
if ![thickness, intensity, posterization].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
if !([thickness, intensity, posterization].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let steps = (posterization.round().clamp(0.0, 6.0) + 1.0) as f32;
@@ -248,7 +248,7 @@ pub(super) fn glow_spread(size: f64) -> f64 {
/// above 0) or from its dark areas (below 0), spread as far as |`size`| (0..24) says and as
/// strong as `brightness` (0..50). Coverage is kept.
pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, brightness: f64, colour: [f32; 3]) {
if ![size, brightness].iter().all(|v| v.is_finite()) || !(space.px.is_finite() && space.px > 0.0) {
if !([size, brightness].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) {
return;
}
let strength = (brightness / 50.0).clamp(0.0, 1.0) as f32;
+43
View File
@@ -2133,6 +2133,49 @@ fn artistic_params_take_defaults_and_stay_in_range() {
}
}
#[test]
fn artistic_filters_leave_invalid_coordinate_spaces_alone() {
let (w, h) = (16, 12);
let src = ramp(w, h);
let run = |f: &PixelFx, s: &PixelSpace| {
let mut out = src.clone();
f.apply(&mut out, w, h, s);
out
};
let colour = [0.3, 0.65, 1.0];
let filters = [
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 },
PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour },
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 },
];
for f in &filters {
assert_ne!(run(f, &space(w, h)), src, "{f:?} changes the art in a valid space");
}
let mut changed = Vec::new();
for f in &filters {
for px in [0.0, -1.0, f64::NAN, f64::INFINITY] {
if run(f, &PixelSpace { px, ..space(w, h) }) != src {
changed.push(format!("{f:?} at px {px}"));
}
}
}
for f in [
PixelFx::Cutout { levels: f64::NAN, simplicity: 4.0, fidelity: 2.0 },
PixelFx::Cutout { levels: 4.0, simplicity: f64::INFINITY, fidelity: 2.0 },
PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: f64::NEG_INFINITY },
PixelFx::NeonGlow { size: f64::NAN, brightness: 15.0, colour },
PixelFx::NeonGlow { size: 5.0, brightness: f64::INFINITY, colour },
PixelFx::PosterEdges { thickness: f64::NAN, intensity: 1.0, posterization: 2.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: f64::INFINITY, posterization: 2.0 },
PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: f64::NEG_INFINITY },
] {
if run(&f, &space(w, h)) != src {
changed.push(format!("{f:?}"));
}
}
assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}");
}
#[test]
fn cutout_paints_a_few_flat_colours_and_keeps_coverage() {
let (w, h) = (64, 24);
+1 -1
View File
@@ -197,7 +197,7 @@ impl Rasterize {
let (px, rest) = data.as_chunks_mut::<4>();
let (src, src_rest) = image.as_chunks::<4>();
let fits = |n: usize, w: usize, h: usize, rest: usize| w > 0 && h > 0 && rest == 0 && w.checked_mul(h) == Some(n);
if !fits(px.len(), w, h, rest.len()) || !fits(src.len(), cols, rows, src_rest.len()) || !(rect.width() > 0.0 && rect.height() > 0.0) {
if !(fits(px.len(), w, h, rest.len()) && fits(src.len(), cols, rows, src_rest.len()) && rect.width() > 0.0 && rect.height() > 0.0) {
return;
}
let (sx, sy) = (cols as f64 / rect.width(), rows as f64 / rect.height());
+31 -3
View File
@@ -34,6 +34,8 @@ pub fn revolve_options(p: &Value) -> Revolve {
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
light_behind: flag(p, "lightBehind", false),
light_color: crate::lighting::light_color(p),
lights: crate::lighting::lights(p),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
@@ -46,6 +48,7 @@ pub fn revolve_options(p: &Value) -> Revolve {
/// Validate before a command mutates the document. Loading external files is still bounded
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
@@ -87,7 +90,9 @@ fn surface(
let b = path.bounds()?;
// A loaded file or a profile edited past the limits keeps drawing its source path instead
// of crashing the frame. Commands validate the same evaluator and report its errors.
let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
let options = revolve_options(&merged_params(REVOLVE, &fx.params));
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
// Visibility is an expansion option, never a change to live geometry or export baking.
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
// failure also retains the complete surface rather than losing any of the user's art.
@@ -102,19 +107,42 @@ fn surface(
let faces = visible.unwrap_or_else(|| {
faces
.into_iter()
.map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient })
.map(|f| vectorcraft_three_d::VisibleFace {
path: f.path(),
brightness: f.brightness,
depth: f.depth,
gradient: f.gradient,
illumination: f.illumination,
light_gradient: f.light_gradient,
rgb_gradient: f.rgb_gradient,
})
.collect()
});
let children = faces
.into_iter()
.map(|face| {
if let Some(g) = face.rgb_gradient
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
{
return Arc::new(crate::lighting::rgb_gradient_art(n.id, &face.path, &paint, g));
}
if let (true, Paint::Gradient(_), Some(g)) = (colored, &paint, face.light_gradient) {
return Arc::new(crate::lighting::gradient_art(n.id, &face.path, &paint, g));
}
let mut fill = FillLayer::new(paint.clone());
let mut items = vec![];
if let Some(c) = paint.color() {
let [r, g, b] = c.to_rgb();
let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
fill.paint = face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0));
fill.paint = if colored {
face.light_gradient
.map_or_else(|| Paint::solid(crate::lighting::shade(c, face.illumination)), |g| crate::lighting::smooth_paint(g, c))
} else {
face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0))
};
items.push(AppearanceItem::Fill(fill));
} else if colored && matches!(paint, Paint::Gradient(_)) {
items = crate::lighting::fills(&paint, face.illumination, face.brightness, true);
} else {
items.push(AppearanceItem::Fill(fill));
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
+35 -17
View File
@@ -3,11 +3,14 @@
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
#[cfg(test)]
use vectorcraft_doc::color::Color;
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry};
@@ -65,6 +68,8 @@ pub fn view_options(p: &Value) -> View {
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
light_behind: flag(p, "lightBehind", false),
light_color: crate::lighting::light_color(p),
lights: crate::lighting::lights(p),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
@@ -124,6 +129,7 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) ->
/// Validate before a command changes the document; the evaluator bounds loaded files too.
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
let (path, rule) =
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
@@ -139,32 +145,38 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
/// blend mode (so overlapping facets don't add up their transparency).
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode) -> Node {
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
if colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) {
let mut group = crate::lighting::solid_surface(n.id, &facets, paint);
group.opacity = opacity;
group.blend = blend;
return group;
}
// Preserve existing white-light paints, alpha composition and seam joins exactly.
let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) };
let paint = &opaque;
let children = facets
.into_iter()
.map(|face| {
let mut items = vec![];
if let Some(c) = paint.color() {
let [r, g, b] = c.to_rgb();
let s = face.brightness as f32;
items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(r * s, g * s, b * s)))));
} else {
// Gradients and patterns keep their paint, darkened by a black layer.
items.push(AppearanceItem::Fill(FillLayer::new(paint.clone())));
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
shadow.opacity = (1.0 - face.brightness) as f32;
items.push(AppearanceItem::Fill(shadow));
}
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
// stroke of the same paint closes them. No wider than the facet is thick, though: seen
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
// paint over its neighbours.
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
if let Some(g) = face.rgb_gradient
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
{
return Arc::new(crate::lighting::facet_art(n.id, &face.path(), paint, g, seam));
}
let items = crate::lighting::fills(paint, face.illumination, face.brightness, colored);
let items = items
.into_iter()
.flat_map(|item| {
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
let mut edge = StrokeLayer::new(fill.paint.clone(), seam);
if colored {
edge.join = vectorcraft_doc::LineJoin::Round;
}
edge.opacity = fill.opacity;
vec![item, AppearanceItem::Stroke(edge)]
})
@@ -175,6 +187,11 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
if let Some(mask) = mask {
let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0));
group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true)));
}
group
}
@@ -200,6 +217,7 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
let b = path.bounds()?;
let p = crate::merged_params(&fx.id, &fx.params);
let colored = view_options(&p).has_colored_lights();
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds, so they stay together.
let mut out = vec![];
@@ -207,7 +225,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
let (paint, opacity, blend) = paint_of(item);
let (area, rule) = region(path, rule, item);
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
}
return Some(out);
}
@@ -216,7 +234,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
// failing the frame; commands validate the same evaluator and report its errors.
let f = facets(&fx.id, path, rule, b, &p).ok()?;
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))])
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
+44
View File
@@ -834,6 +834,50 @@ fn drop_shadow_darkness_replaces_its_color_when_chosen() {
assert_eq!(darkness(json!({})), None);
}
#[test]
fn rasterize_compose_leaves_the_raster_alone_when_the_sizes_do_not_fit() {
let r = match raster_effects(&[fx(RASTERIZE, json!({}))]).as_slice() {
[RasterFx::Rasterize(r)] => *r,
other => panic!("{other:?}"),
};
let space = PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(1.0, 1.0),
channels: pixel::Channels::Rgb,
line: 1.0,
extent: std::f64::consts::SQRT_2,
};
let compose = |len: usize, w: usize, h: usize, image: &[u8], cols: usize, rows: usize, rect: Rect| {
let mut data = vec![7; len];
r.compose(&mut data, w, h, &space, image, cols, rows, rect);
data
};
// A 2 × 2 red image over a 2 × 2 raster of one-point pixels: each pixel takes the image's color.
let red = [255, 0, 0, 255].repeat(4);
let mut long = red.clone();
long.extend([255, 0]);
let (img, long, area) = (red.as_slice(), long.as_slice(), Rect::new(0.0, 0.0, 2.0, 2.0));
assert_eq!(compose(16, 2, 2, img, 2, 2, area), red);
let cases = [
("w × h is not the raster's pixel count", 16, 1, 2, img, 2, 2, area),
("the raster's length is not a multiple of 4", 18, 2, 2, img, 2, 2, area),
("cols × rows is not the image's pixel count", 16, 2, 2, img, 1, 2, area),
("the image's length is not a multiple of 4", 16, 2, 2, long, 2, 2, area),
("the image has no width", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 0.0, 2.0)),
("the image has no height", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 2.0, 0.0)),
("the image has a negative width", 16, 2, 2, img, 2, 2, Rect::new(2.0, 0.0, 0.0, 2.0)),
("the image has a NaN width", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, f64::NAN, 2.0)),
("the image has a NaN height", 16, 2, 2, img, 2, 2, Rect::new(0.0, 0.0, 2.0, f64::NAN)),
];
let failures: Vec<_> = cases
.iter()
.filter(|&&(_, len, w, h, image, cols, rows, rect)| compose(len, w, h, image, cols, rows, rect) != vec![7; len])
.map(|c| c.0)
.collect();
assert!(failures.is_empty(), "{failures:?}");
}
#[test]
fn blend_mode_names() {
use vectorcraft_doc::color::BlendMode;
+59 -5
View File
@@ -27,7 +27,10 @@ fn faces(art: &Node) -> Vec<&Node> {
fn fill_rgb(n: &Node) -> [f32; 3] {
match &n.appearance.items[0] {
AppearanceItem::Fill(f) => f.paint.color().unwrap().to_rgb(),
AppearanceItem::Fill(f) => match &f.paint {
Paint::Gradient(g) => g.gradient.sample(0.5).0.to_rgb(),
p => p.color().unwrap().to_rgb(),
},
_ => panic!("a fill first"),
}
}
@@ -183,10 +186,12 @@ fn an_earlier_perspective_strength_draws_as_its_angle() {
#[test]
fn an_earlier_light_direction_shades_as_its_rotation_and_height() {
fn colours(n: &Node, out: &mut Vec<[f32; 3]>) {
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first()
&& let Some(c) = f.paint.color()
{
out.push(c.to_rgb());
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() {
if let Paint::Gradient(g) = &f.paint {
out.extend(g.gradient.stops.iter().map(|s| s.color.to_rgb()));
} else if let Some(c) = f.paint.color() {
out.push(c.to_rgb());
}
}
for c in n.children().into_iter().flatten() {
colours(c, out);
@@ -265,3 +270,52 @@ fn inflate_reads_twist_taper_and_cap() {
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
}
#[test]
fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
let mut n = square_node();
n.appearance.effects.push(fx(INFLATE, json!({})));
let art = solid_art(&n).unwrap();
let original = faces(&art);
assert!(original.len() > 1000);
assert!(original.iter().any(|f| f.path_data().unwrap().subpaths.iter().any(|p| p.anchors.len() > 3)), "legacy cells remain polygons");
for face in original {
assert!(matches!(face.kind, NodeKind::Path { .. }));
let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") };
assert!((0.05..=0.75).contains(&stroke.width));
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter);
}
// A tiny shape exercises the lower bound, which otherwise disappears at grazing angles.
n.transform(Affine::scale(0.001), false);
let art = solid_art(&n).unwrap();
assert!(faces(&art).iter().any(|face| matches!(&face.appearance.items[1], AppearanceItem::Stroke(s) if s.width == 0.05)));
// An explicit white light overrides a stale legacy color without switching shading paths.
n.appearance.effects[0].params = json!({"lightColor":"#ff0000","lights":[{}]});
let art = solid_art(&n).unwrap();
assert!(faces(&art).iter().all(|face| matches!(face.kind, NodeKind::Path { .. })));
}
#[test]
fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
let mut n = square_node();
n.appearance.effects.push(fx(INFLATE, json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]})));
let art = solid_art(&n).unwrap();
fn count(n: &Node) -> (usize, usize, usize) {
let (mut groups, mut clips, mut paths) = match &n.kind {
NodeKind::Group { clip, .. } => (1, usize::from(*clip), 0),
NodeKind::Path { .. } => (0, 0, 1),
_ => (0, 0, 0),
};
for child in n.children().into_iter().flatten() {
let (g, c, p) = count(child);
groups += g;
clips += c;
paths += p;
}
(groups, clips, paths)
}
let (groups, clips, paths) = count(&art);
assert!(paths > 1000, "exercise a tessellated surface");
assert_eq!(clips, 1);
assert_eq!(groups, 7, "group count must not grow with triangle count");
}
+4
View File
@@ -15,6 +15,8 @@ default = ["bundled-fonts"]
bundled-fonts = ["vectorcraft-text/bundled-fonts"]
[dependencies]
# Which paths stored in documents may be opened.
craft-docpath = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
@@ -35,6 +37,8 @@ vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
# The XML parts of Word documents placed as type (place/docx.rs).
roxmltree = "0.21"
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+21 -10
View File
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Brush…",
[],
None,
"{type: calligraphic|scatter|art|pattern|bristle, name?, params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
"{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New <Type> Brush), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}",
has_doc,
brush_new
),
@@ -75,13 +75,21 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
brush_delete
),
cmd!("brush.duplicate", "Duplicate Brush", [], None, "{name, newName?} | {names: [name…]} → {name} | {names}", has_doc, brush_duplicate),
cmd!(
"brush.duplicate",
"Duplicate Brush",
[],
None,
"{name, newName? (trimmed; blank: <name> copy)} | {names: [name…]} → {name} | {names}",
has_doc,
brush_duplicate
),
cmd!(
"brush.options",
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName?} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
@@ -202,7 +210,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Symbolism Tool",
[],
None,
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, style?: name} adjust instances near the points (selection, else all) → {count}",
"{tool: shift|scrunch|size|spin|stain|screen|style, points: [[x,y]…], radius?: 40, intensity?: 1..10 (5), alt?: bool, color?, keepModel?: bool (as paint.setFill), style?: name} adjust instances near the points (selection, else all) → {count}",
has_doc,
symbol_adjust
),
@@ -393,7 +401,8 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
o => return Err(bad(C, format!("unknown brush type `{o}`"))),
};
let mut lib = brush::library(&s.doc()?.doc);
let base_name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]));
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string);
let name = brush::unique_name(&lib, &base_name);
let mut v = json!({"name": name, "type": ty});
if let Some(params) = p.get("params") {
@@ -445,7 +454,8 @@ fn brush_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = vec![];
for name in &names {
let mut b = before.iter().find(|b| &b.name == name).cloned().ok_or_else(|| bad(C, format!("no brush named `{name}`")))?;
let new_name = str_param(p, "newName").filter(|_| p.get("names").is_none()).map_or_else(|| format!("{name} copy"), str::to_string);
let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && p.get("names").is_none());
let new_name = given.map_or_else(|| format!("{} copy", name.trim()), str::to_string);
b.name = brush::unique_name(&lib, &new_name);
out.push(b.name.clone());
lib.push(b);
@@ -463,7 +473,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(params) = p.get("params") {
merge(&mut v, params);
}
let new_name = str_param(p, "newName").filter(|n| !n.trim().is_empty() && *n != name).map(str::to_string);
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
if let Some(nn) = &new_name {
if lib.iter().any(|b| &b.name == nn) {
return Err(bad(C, format!("a brush named `{nn}` exists")));
@@ -669,7 +679,7 @@ fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
let ids = roots(s, p)?;
let art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let (art, size, c) = normalize(art)?;
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or("New Symbol"));
let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("New Symbol"));
let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER);
let export = export_type_param(p, C)?.unwrap_or_default();
let nine = nine_slice_param(p, C, None)?.flatten();
@@ -918,7 +928,8 @@ fn symbol_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, C)?.to_string();
let d = &s.doc()?.doc;
let sym = d.symbols.iter().find(|x| x.name == name).cloned().ok_or_else(|| bad(C, format!("no symbol named `{name}`")))?;
let new = d.unique_symbol_name(str_param(p, "newName").unwrap_or(&format!("{name} copy")));
let given = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty());
let new = d.unique_symbol_name(&given.map_or_else(|| format!("{} copy", name.trim()), str::to_string));
let size = natural_size(d, &name);
let reg = d.symbol_registration(&name);
let export = d.symbol_export_type(&name);
@@ -1099,7 +1110,7 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
let alt = bool_or(p, "alt", false);
let st = s.doc()?;
let color = match p.get("color").and_then(color_value) {
Some(c) => c,
Some(c) => super::paint::new_color(s, p, c),
None => s.paint.fill.color().unwrap_or(Color::BLACK),
};
let style_paint = match str_param(p, "style") {
+1 -1
View File
@@ -232,7 +232,7 @@ fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
Some(v @ Value::Object(o)) if o.contains_key("color") || o.contains_key("swatch") || o.contains_key("gradient") || o.contains_key("none") => {
paint_from(s, v)
}
Some(v) => color_value(v).map(|c| Some(Paint::solid(c))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
Some(v) => color_value(v).map(|c| Some(Paint::solid(super::paint::new_color(s, p, c)))).ok_or_else(|| bad(cmd, format!("bad fill {v}"))),
}
}
+45 -22
View File
@@ -130,7 +130,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clipboard as PNG",
[],
None,
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px)} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty)",
"{scale?: 1 (pixels per point, up to 64)} → {dataBase64, width, height (px), warnings} the copied objects cropped to their visual bounds, transparent around them (dataBase64 null when the clipboard is empty); warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)",
always,
export_png
),
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clipboard as PDF",
[],
None,
"{} → {dataBase64} a one-page PDF of the copied objects, the page their visual bounds (null when the clipboard is empty)",
"{} → {dataBase64, warnings} a one-page PDF of the copied objects, the page their visual bounds (dataBase64 null when the clipboard is empty); warnings as for document.export",
always,
export_pdf
),
@@ -218,6 +218,9 @@ fn type_only(n: &Node) -> bool {
}
}
/// A PNG of the copied objects: (bytes, width, height in pixels, the encoder's warnings).
pub type Png = (Vec<u8>, u32, u32, Vec<String>);
impl Clipboard {
/// The copied type objects' text, one object per line (`None` unless it holds type only).
pub fn text(&self) -> Option<String> {
@@ -243,21 +246,21 @@ impl Clipboard {
Some((fileio::with_single_artboard(self.to_document(), b, "Clipboard"), b))
}
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height); `None` when
/// there are none.
pub fn png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
/// A PNG of the objects at `scale` pixels per point → (bytes, width, height, the warnings
/// [`fileio::encode_with_warnings`] gives); `None` when there are none.
pub fn png(&self, scale: f64) -> Result<Option<Png>> {
const C: &str = "clipboard.exportPng";
let Some((doc, b)) = self.cropped() else { return Ok(None) };
let (w, h) = vectorcraft_render::raster_size(b, scale).map_err(|e| bad(C, e))?;
let png = fileio::encode(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
Ok(Some((png, w, h)))
let (png, warnings) = fileio::encode_with_warnings(&doc, "png", &json!({ "scale": scale, "background": "transparent" }))?;
Ok(Some((png, w, h, warnings)))
}
/// A one-page PDF of the objects (without the native document inside); `None` when there are
/// none.
pub fn pdf(&self) -> Result<Option<Vec<u8>>> {
/// A one-page PDF of the objects (without the native document inside) → (bytes, the encoder's
/// warnings); `None` when there are none.
pub fn pdf(&self) -> Result<Option<(Vec<u8>, Vec<String>)>> {
let Some((doc, _)) = self.cropped() else { return Ok(None) };
fileio::encode(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
fileio::encode_with_warnings(&doc, "pdf", &json!({ "preserveEditing": false })).map(Some)
}
/// The MIME types of [`Self::flavours`], without making them.
@@ -275,23 +278,38 @@ impl Clipboard {
/// (`pdf`) as given (see `clipboard.flavours`). A [`Flavour`] that can't be made (art too large
/// to render) is left out; the PNG is scaled down to at most [`COPY_PNG_SIDE`] pixels a side.
pub fn flavours(&self, svg: bool, pdf: bool) -> Vec<Flavour> {
self.flavours_with_warnings(svg, pdf).0
}
/// [`Self::flavours`], with the warnings of the PDF and PNG encoders, each once (an image that
/// couldn't be decoded, type in the fallback font).
pub fn flavours_with_warnings(&self, svg: bool, pdf: bool) -> (Vec<Flavour>, Vec<String>) {
let types = self.flavour_types(svg, pdf);
let svg = types.contains(&SVG).then(|| self.svg()).flatten();
let mut out = vec![];
let mut warnings: Vec<String> = vec![];
let mut keep = |(bytes, notes): (Vec<u8>, Vec<String>)| {
for w in notes {
if !warnings.contains(&w) {
warnings.push(w);
}
}
bytes
};
for mime in types {
let data = match mime {
TEXT => self.text().or_else(|| svg.clone()).map(String::into_bytes),
SVG => svg.clone().map(String::into_bytes),
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten(),
PDF => self.pdf().inspect_err(|e| log::warn!("clipboard PDF: {e}")).ok().flatten().map(&mut keep),
_ => {
let side = self.bounds().map_or(1.0, |b| b.width().max(b.height()));
let scale = (COPY_PNG_SIDE / side).clamp(0.01, 1.0);
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, ..)| png)
self.png(scale).inspect_err(|e| log::warn!("clipboard PNG: {e}")).ok().flatten().map(|(png, _, _, notes)| keep((png, notes)))
}
};
out.extend(data.map(|data| Flavour { mime, data }));
}
out
(out, warnings)
}
}
@@ -302,15 +320,15 @@ impl Session {
self.clipboard.text()
}
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height); `None`
/// when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<(Vec<u8>, u32, u32)>> {
/// A PNG of the copied objects at `scale` pixels per point → (bytes, width, height, the
/// encoder's warnings); `None` when the clipboard is empty.
pub fn clipboard_png(&self, scale: f64) -> Result<Option<Png>> {
self.clipboard.png(scale)
}
/// A one-page PDF of the copied objects (without the native document inside); `None` when the
/// clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<Vec<u8>>> {
/// A one-page PDF of the copied objects (without the native document inside) → (bytes, the
/// encoder's warnings); `None` when the clipboard is empty.
pub fn clipboard_pdf(&self) -> Result<Option<(Vec<u8>, Vec<String>)>> {
self.clipboard.pdf()
}
@@ -356,13 +374,18 @@ fn export_png(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("clipboard.exportPng", "scale must be a number above 0, at most 64"));
}
Ok(match s.clipboard_png(scale)? {
Some((png, w, h)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h }),
Some((png, w, h, warnings)) => {
json!({ "dataBase64": vectorcraft_format::base64_encode(&png), "width": w, "height": h, "warnings": warnings })
}
None => json!({ "dataBase64": null }),
})
}
fn export_pdf(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "dataBase64": s.clipboard_pdf()?.map(|b| vectorcraft_format::base64_encode(&b)) }))
Ok(match s.clipboard_pdf()? {
Some((pdf, warnings)) => json!({ "dataBase64": vectorcraft_format::base64_encode(&pdf), "warnings": warnings }),
None => json!({ "dataBase64": null }),
})
}
fn export_text(s: &mut Session, _: &Value) -> Result<Value> {
+2 -1
View File
@@ -142,7 +142,8 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "colorTheme.addToSwatches";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let t = &s.prefs.color_themes[find(s, name, C)?];
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>() });
// The theme's colors keep their model, in a CMYK document too.
let params = json!({ "name": t.name, "colors": t.colors.iter().map(color_json).collect::<Vec<_>>(), "keepModel": true });
let r = s.execute("swatch.newGroup", &params)?;
Ok(json!({ "name": r["name"], "swatches": r["swatches"] }))
}
+5 -4
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, 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",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, keepModel?: bool (as paint.setFill), 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
),
@@ -573,8 +573,9 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result<vectorcraf
Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default))
}
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
/// defaults, filled with the current fill unless that is None or white (black then).
/// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB
/// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults,
/// filled with the current fill unless that is None or white (black then).
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
let mut style = CharStyle::default();
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
@@ -587,7 +588,7 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
style.font_style = f.to_string();
}
style.fill = match p.get("color").and_then(color_value) {
Some(c) => vectorcraft_color::Paint::solid(c),
Some(c) => vectorcraft_color::Paint::solid(super::paint::new_color(s, p, c)),
None if !s.paint.fill.is_none() && s.paint.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::WHITE) => s.paint.fill.clone(),
None => vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
};
+13 -6
View File
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_svg::{CssOptions, CssSheet, css_rules};
use super::fileio::{default_name, encode, isolated, write_file};
use super::fileio::{default_name, encode_with_warnings, isolated, write_file};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export CSS",
[],
None,
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}",
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
has_doc,
export
),
@@ -100,12 +100,19 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
}
let css = format!("{}\n", sheet.text());
let doc = &s.doc()?.doc;
// The pictures of rasterized art.
// Encode the pictures of rasterized art, and add each of their warnings once.
let mut images = Vec::new();
let mut warnings = sheet.warnings.clone();
for r in &sheet.rules {
let Some(name) = &r.image else { continue };
let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?;
images.push((name, encode(&d, "png", &json!({}))?));
let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?;
images.push((name, png));
for w in notes {
if !warnings.contains(&w) {
warnings.push(w);
}
}
}
let n = sheet.rules.len();
match str_param(p, "path") {
@@ -118,12 +125,12 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
write_file(&at, bytes)?;
written.push(at);
}
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": sheet.warnings }))
Ok(json!({ "path": path, "bytes": css.len(), "rules": n, "images": written, "warnings": warnings }))
}
None => {
let images: Vec<Value> =
images.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": sheet.warnings }))
Ok(json!({ "data": css, "name": default_name(doc, "css"), "rules": n, "images": images, "warnings": warnings }))
}
}
}
+28 -2
View File
@@ -75,6 +75,15 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!(
"edit.clearHistory",
"Clear History",
[],
None,
"{} remove every undo and redo step of the active document (the History panel menu's Clear History): the document stays as it is, and clearing is not an undo step → {cleared: the number of steps removed}",
can_clear_history,
clear_history
),
cmd!(
"edit.undoGroup.begin",
"Begin Undo Group",
@@ -380,7 +389,9 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.redo.last());
st.history.redo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
let mut turned = crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs);
turned.named = e.named;
st.history.redo.push(turned);
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
@@ -403,7 +414,9 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let doc = std::mem::replace(&mut st.doc, e.doc);
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
let selection = shared_selection(selection, st.history.undo.last());
st.history.undo.push(crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs));
let mut turned = crate::HistoryEntry::turned(e.label, doc, selection, &st.doc, costs);
turned.named = e.named;
st.history.undo.push(turned);
st.history.trim();
st.revision += 1;
st.selection.prune(&st.doc);
@@ -414,6 +427,19 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "redone": label }))
}
/// History panel › Clear History: removes the steps after the current state and the steps before
/// it, and leaves the document as it is. Typing or a drag still in progress is first kept as a
/// step, as Undo keeps it, so it is removed with the others and its change stays in the document.
fn clear_history(s: &mut Session, _: &Value) -> Result<Value> {
settle_interaction(s)?;
let st = s.doc_mut()?;
let cleared = st.history.undo.len() + st.history.redo.len();
st.history.undo.clear();
st.history.redo.clear();
st.revision += 1;
Ok(json!({ "cleared": cleared }))
}
/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same.
fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc<vectorcraft_doc::Selection> {
match like {
+2
View File
@@ -139,6 +139,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`params` must be an object"));
}
super::params::check_effect_color(C, id, &params)?;
super::params::check_effect_channel(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());
@@ -317,6 +318,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
let edited = set_params_effects(s, p);
for effect in &edited {
super::params::check_effect_color(C, effect, &params)?;
super::params::check_effect_channel(C, effect, &params)?;
}
if edited.iter().any(|e| e == GLASS) {
load_texture(&mut params, C)?;
+10 -15
View File
@@ -318,7 +318,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
/// fill's opacity, blend mode and effects other than geometry effects ([`fill_effects`]).
fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option<Node> {
let content = if g.gradient.kind == GradientKind::Freeform {
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?;
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
} else {
let frame = Frame::new(g, bounds);
@@ -720,29 +720,24 @@ fn tile_art(d: &mut Document, l: &Node, f: &FillLayer, tiles: &[Node]) -> Option
/// The colour and opacity gradient fill `g` paints at a document point of an object whose box is
/// `bounds`, as the canvas draws it (the end colours continue past the ends). Colours stay in the
/// colour model their stops (or freeform points) share; display RGB where models differ.
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
/// color model their stops share; display RGB where models differ. Freeform points mix in the
/// model they share ([`vectorcraft_color::Freeform::model_field`]); where their models differ, the
/// RGB color they mix to is stored in `doc_model` (the document's model) the way `paint.setFill`
/// stores an RGB color ([`super::paint::stored_color`]).
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
if g.gradient.kind == GradientKind::Freeform {
let b = bounds.abs();
let f = g.freeform_on(b);
let model = shared_model(f.points.iter().map(|p| p.color));
let field = f.field(spread_scale(b));
let field = g.freeform_on(b).model_field(spread_scale(b));
let store_in = (field.model().is_none() && doc_model != Model::Rgb).then_some(doc_model);
return Box::new(move |p| {
let ([r, g, b], a) = field.sample(p);
let c = Color::rgb(r, g, b);
(model.map_or(c, |m| c.in_model(m)), a)
let (c, a) = field.sample(p);
(super::paint::stored_color(store_in, c), a)
});
}
let geom = g.resolve(bounds);
Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color))
}
/// The colour model all of `colors` share.
fn shared_model(mut colors: impl Iterator<Item = Color>) -> Option<Model> {
let m = colors.next()?.model();
colors.all(|c| c.model() == m).then_some(m)
}
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
/// stop between them (in its own colour, so coincident stops keep a sharp edge) and every midpoint
/// off the centre, as (parameter, colour, opacity) in order, with more between them where they
+114 -5
View File
@@ -140,8 +140,9 @@ fn batch_datasets(s: &mut Session, cmd: &str, run: &Run<'_>) -> Result<Value> {
}
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.
// destination uses the same name). `file_part` writes the characters Windows does not allow in a
// file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters) 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 };
@@ -246,7 +247,8 @@ fn write_result(s: &mut Session, path: &str, ext: &str, as_copy: bool) -> Result
Ok(())
}
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on.
/// Point Save / Export steps at `dest_folder/{stem}.{ext}` when overrides are on: `ext` is the
/// extension of the step's `format` ([`format_extension`]), else `default_ext`.
#[cfg(not(target_arch = "wasm32"))]
fn rewrite_paths(
steps: &[Value],
@@ -271,8 +273,10 @@ fn rewrite_paths(
}
let params = c.get("params").cloned().unwrap_or(json!({}));
let mut params = params.as_object().cloned().unwrap_or_default();
let ext = params.get("format").and_then(Value::as_str).unwrap_or(default_ext);
let ext = if ext.is_empty() { default_ext } else { ext };
let ext = match params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()) {
Some(f) => format_extension(f),
None => default_ext.to_string(),
};
let file = std::path::Path::new(folder).join(format!("{stem}.{ext}"));
params.insert("path".into(), json!(file.to_string_lossy()));
if let Some(o) = c.as_object_mut() {
@@ -283,6 +287,21 @@ fn rewrite_paths(
.collect()
}
/// The extension of the file a step writes in `format` (an id or extension, as [`super::format`]
/// reads it: any case, a leading dot allowed): the extension as written, in lower case and without
/// the dot, when it is one of the format's (`SVG` → `svg`, `.JPEG` → `jpeg`), else the format's
/// first (`png8` → `png`, `template` → `vctemplate`). An unknown format (`xyz`) keeps its spelling,
/// and the step fails with its command's error.
#[cfg(not(target_arch = "wasm32"))]
fn format_extension(format: &str) -> String {
let written = format.trim_start_matches('.').to_ascii_lowercase();
match super::format(format) {
Some(f) if f.extensions.contains(&written.as_str()) => written,
Some(f) => f.extensions.first().map_or(written, |e| (*e).to_string()),
None => format.to_string(),
}
}
/// Readable files under `folder` (and optionally its subfolders), capped.
#[cfg(not(target_arch = "wasm32"))]
fn collect_files(folder: &str, include_subdirs: bool, depth: usize) -> Result<Vec<String>> {
@@ -410,6 +429,70 @@ mod tests {
assert!(dest.join("only.svg").is_file());
}
/// Override Export names each file with the extension of the format the step names, however
/// the step spells it; a format given by one of its extensions keeps that extension.
#[test]
fn batch_override_export_names_files_with_the_format_s_extension() {
let src = tmp("ext-src");
write_svg(&src, "a.svg", "#f00");
let cases = [(".svg", "a.svg"), ("SVG", "a.svg"), ("svg", "a.svg"), ("png8", "a.png"), ("template", "a.vctemplate"), (".JPEG", "a.jpeg")];
for (i, (format, want)) in cases.into_iter().enumerate() {
let dest = tmp(&format!("ext-out-{i}"));
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.export", "params": {"format": format}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{format}: {r}");
assert_eq!(file_names(&dest), [want], "{format}: {r}");
}
// An unknown format keeps its spelling, and the step fails with the export's error.
let dest = tmp("ext-out-unknown");
let mut s = Session::new();
let r = s
.execute(
"action.batch",
&json!({
"folder": src.to_string_lossy(),
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "document.export", "params": {"format": "xyz"}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 1, "{r}");
assert!(r["processed"][0]["error"].as_str().is_some_and(|e| e.contains("unknown format `xyz`")), "{r}");
assert!(file_names(&dest).is_empty(), "{r}");
}
/// Override Save names each file with the extension of the format the step names, also over
/// data sets.
#[test]
fn batch_override_save_names_files_with_the_format_s_extension() {
for (i, format) in [".svg", "SVG", ".PDF"].into_iter().enumerate() {
let dest = tmp(&format!("ext-save-{i}"));
let mut s = with_data_sets(&["a"]);
let r = s
.execute(
"action.batch",
&json!({
"source": "datasets",
"destinationFolder": dest.to_string_lossy(),
"commands": [{"command": "file.saveAs", "params": {"format": format}}]
}),
)
.unwrap();
assert_eq!(r["failed"], 0, "{format}: {r}");
let want = format!("Untitled_a.{}", format.trim_start_matches('.').to_ascii_lowercase());
assert_eq!(file_names(&dest), [want], "{format}: {r}");
}
}
#[test]
fn batch_datasets_applies_each_row() {
let dest = tmp("sets");
@@ -516,6 +599,32 @@ mod tests {
assert_eq!(file_names(&exports), ["Untitled_a-b.svg", "Untitled_c-d.svg", "Untitled_e-f.svg"], "{r}");
}
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name:
/// Batch writes them as `-` in a data set's name, on every platform.
#[test]
fn batch_saves_data_sets_named_with_characters_windows_does_not_allow() {
let dest = tmp("set-names-win");
let mut s = with_data_sets(&["a?b", "c*d<e>", "f\"g|h", "i\u{7}j"]);
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-e-.vectorcraft", "Untitled_f-g-h.vectorcraft", "Untitled_i-j.vectorcraft"],
"{r}"
);
}
#[test]
fn batch_gives_each_data_set_a_file_of_its_own() {
let dest = tmp("set-names-same");
+5 -6
View File
@@ -12,8 +12,7 @@ use crate::DocState;
use super::super::*;
use super::encode::single_artboard;
use super::{
ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded,
write_or_return,
ARTBOARD_PARAMS, Format, default_name, encode_all, encode_with_warnings, merge, with_black_output, writable, write_encoded, write_or_return,
};
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
@@ -173,7 +172,7 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
/// layers are guides and are left out.
pub(crate) fn selected_artwork(s: &mut Session) -> Result<Vec<NodeId>> {
pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
let ids = edit::selected_roots(s)?;
let st = s.doc()?;
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
@@ -224,7 +223,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
}
/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless
/// `transparent`.
/// `transparent`, with the encoder's warnings.
pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportForOffice";
if let Some(path) = str_param(p, "path") {
@@ -242,8 +241,8 @@ pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
}
let background = if bool_or(p, "transparent", false) { "transparent" } else { "white" };
let opts = with_black_output(s, &json!({ "ppi": ppi, "background": background, "artboard": artboard }));
let bytes = encode(doc, "png", &opts)?;
let (bytes, warnings) = encode_with_warnings(doc, "png", &opts)?;
// The pixel size, from the PNG header.
let side = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map_or(0, u32::from_be_bytes);
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20) }))
write_or_return(path, &bytes, json!({ "width": side(16), "height": side(20), "warnings": warnings }))
}
+22 -9
View File
@@ -15,7 +15,9 @@
//! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha
//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art
//! under it, rendered from the document as it is, clipped to the area where it changes what shows;
//! the art under it stays as it was. With `omit` they are left out.
//! the art under it stays as it was. With `omit` they are left out. An image that can't be decoded
//! is missing from the images that flattening and expanding raster effects draw, and the warnings
//! then carry the raster exports' note once.
//!
//! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each
//! layer's hidden objects go to a hidden layer of their own just above it, with `omit` they are
@@ -33,15 +35,20 @@ use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath};
use super::super::*;
use super::{ArtboardPick, Encoded, Format};
/// Does `p` ask for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown version is an error.
pub(super) fn wanted(cmd: &str, p: &Value) -> Result<bool> {
match p.get("aiVersion") {
None | Some(Value::Null) => Ok(false),
Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => Ok(false),
Some(Value::String(v)) if v.trim() == "7" => Ok(true),
Some(Value::Number(n)) if n.as_f64() == Some(7.0) => Ok(true),
Some(v) => Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))),
/// Does `p` ask a save in `f` for a legacy `.ai` file of version 7 (`aiVersion: "7"`)? An unknown
/// version is an error, and so is version 7 for a `.ait` template, which is PDF-compatible only.
pub(super) fn wanted(cmd: &str, f: &Format, p: &Value) -> Result<bool> {
let legacy = match p.get("aiVersion") {
None | Some(Value::Null) => false,
Some(Value::String(v)) if v.trim().is_empty() || v.eq_ignore_ascii_case("pdf") => false,
Some(Value::String(v)) if v.trim() == "7" => true,
Some(Value::Number(n)) if n.as_f64() == Some(7.0) => true,
Some(v) => return Err(bad(cmd, format!("aiVersion {v}: \"pdf\" (PDF-compatible, the default) or \"7\" (legacy .ai, version 7)"))),
};
if legacy && f.id == "ait" {
return Err(bad(cmd, "aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible"));
}
Ok(legacy)
}
/// What a legacy `.ai` does with the images it can't hold (`transparentImages`).
@@ -104,6 +111,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
enc.warnings.push(SEPARATE.to_string());
}
let created = vectorcraft_doc::metadata::now_unix();
let failed = vectorcraft_render::decode_failures();
for b in boards {
let one = super::save::artboard_doc(doc, b);
let (full, placed) = crate::cmd::place::document::full_documents(&one);
@@ -127,6 +135,11 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
}
enc.files.push((Some(b), out.bytes));
}
// Expanding raster effects and flattening images draw the art, and an image the renderer
// couldn't decode drew nothing there.
if let Some(w) = super::super::rasterfx::undecoded_warning(failed) {
note(&mut enc.warnings, w);
}
Ok(enc)
}
+14 -2
View File
@@ -270,9 +270,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
{
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
}
// Threaded type read from a file flows through its frames.
// Threaded type read from a file flows through its frames (the threaded type of a restored
// document keeps its text as saved), and every frame of a thread takes its first frame's
// Optical Margin Alignment setting.
if !doc.text_threads.is_empty() && !restored {
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
} else if !doc.text_threads.is_empty() {
crate::cmd::threads::share_optical_margin(&mut doc);
}
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
@@ -293,8 +297,11 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document,
// The importer reads on a thread of its own (for its stack): what may be read is what may be
// read here.
let scope = crate::file_access::ScopeHandle::capture();
// Paths are checked as stored in a document inside `folder` (only the folder is used).
let document = folder.map(|f| std::path::Path::new(f).join("-").to_string_lossy().into_owned());
let read = |path: &str| {
scope.clone().run(|| {
let path = &super::document_path(path, document.as_deref()).ok()?;
// Only files (not folders or devices) are read.
file_stamp(path)?;
let bytes = read_file(path).ok()?;
@@ -470,6 +477,8 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
/// 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`]).
/// 1-bit TIFFs compressed with CCITT Group 3, which the decoder can't read either, become RGBA
/// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]).
/// 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
@@ -504,7 +513,10 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
};
let img = match (f.id == "tiff").then(extra_samples).flatten() {
Some(img) => img,
None => reader.decode().map_err(err)?.to_rgba8(),
None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() {
Some(read) => read.map_err(err)?,
None => reader.decode().map_err(err)?.to_rgba8(),
},
};
as_png(img, ppi)
}
+80 -18
View File
@@ -8,7 +8,7 @@
//! - `svg`: the SVG Options (styling, fonts, images, object ids, artboards…).
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForOffice`.
//! - `screens`: Export for Screens (`document.exportForScreens`, `document.exportSettings`); `zip`
//! packs its files as one download.
//! packs its files as one download, and reads the files of Word documents placed as type.
//! - `save`: `document.save`, Save As / a Copy / as Template, New from Template, Revert and the
//! per-format options (`file.formatOptions`); [`save_with`] is the one save path of every frontend.
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
@@ -40,12 +40,13 @@ pub mod pdf;
mod pdfimport;
pub mod pngtext;
pub mod ppi;
pub mod progress;
pub mod psdread;
mod save;
mod screens;
mod svg;
mod text;
mod zip;
pub mod zip;
use serde_json::{Value, json};
@@ -75,10 +76,11 @@ pub use save::{
};
pub use screens::{
PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize,
builtin_presets as screen_export_builtin_presets, preset_rows as screen_preset_rows,
builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_rows as screen_preset_rows,
};
pub(crate) use screens::{
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, store_settings as store_screen_settings,
SHARED_KEYS as SCREEN_SETTINGS_KEYS, check_settings as check_screen_settings, export as export_screens, safe_file_name,
store_settings as store_screen_settings,
};
pub use svg::options_map as svg_options;
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
@@ -114,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
has_doc,
export::serialize
),
@@ -123,7 +125,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…], 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",
"{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; ait, a .ait template, is written by file.saveAsTemplate), 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
),
@@ -141,7 +143,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]). eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
"{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/[email protected]); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
has_doc,
screens::export_for_screens
),
@@ -186,7 +188,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 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}",
"{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 control characters 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
),
@@ -213,7 +215,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Office Documents…",
["File"],
None,
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height} (pixels); no path → {dataBase64, bytes, width, height}",
"{path?, ppi?: 150 (72, 150, 300… pixels per inch), transparent?: false (else on white), artboard?: 0, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out)} the artboard as a PNG for office documents and slides → {path, bytes, width, height, warnings} (pixels); no path → {dataBase64, bytes, width, height, warnings}; warnings as for document.export (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame)",
has_doc,
export::export_for_office
),
@@ -710,7 +712,11 @@ pub const TEXT_EXTS: &[&str] = &["txt"];
/// RTF files: File → Place sets them as area type with their formatting.
pub const RTF_EXTS: &[&str] = &["rtf"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`].
/// Word documents: File → Place sets them as area type with their formatting.
pub const DOCX_EXTS: &[&str] = &["docx"];
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and
/// [`DOCX_EXTS`].
pub const PLACE_EXTS: &[&str] = &[
"vectorcraft",
"drawcraft",
@@ -741,6 +747,7 @@ pub const PLACE_EXTS: &[&str] = &[
"afpub",
"txt",
"rtf",
"docx",
];
/// One dialog filter per readable format.
@@ -764,13 +771,14 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text
/// and RTF.
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text,
/// RTF and Word.
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All placeable files", PLACE_EXTS))
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
.chain(std::iter::once(("Text", TEXT_EXTS)))
.chain(std::iter::once(("RTF", RTF_EXTS)))
.chain(std::iter::once(("Word", DOCX_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
@@ -806,7 +814,8 @@ pub fn format_for_name(name: &str) -> Option<&'static Format> {
Some(extension(name)).filter(|e| !e.is_empty()).and_then(|e| format(&e))
}
/// The format to write: `format` (an id or extension), else the path's extension, else PNG.
/// The format to write: `format` (an id or extension), else the path's extension, else PNG. For a
/// `.ait` template it returns an error that names `file.saveAsTemplate`, which writes it.
pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
// Formats that can't be written say what to use instead.
let unknown = |what: String, key: &str| unsupported(key).map_or(what, |u| u.hint.to_string());
@@ -815,7 +824,13 @@ pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::r
(None, Some(e)) => format(&e).ok_or_else(|| unknown(format!("unknown extension `.{e}`: pass `format` (see document.formats)"), &e))?,
(None, None) => format("png").ok_or("no PNG encoder")?,
};
if f.write { Ok(f) } else { Err(format!("{} files can be opened but not written (see document.formats)", f.label)) }
if f.write {
Ok(f)
} else if TEMPLATE_FORMATS.contains(&f.id) {
Err(format!("{} files are written by file.saveAsTemplate", f.label))
} else {
Err(format!("{} files can be opened but not written (see document.formats)", f.label))
}
}
/// [`writable_format`] for command `cmd`.
@@ -843,9 +858,36 @@ fn formats(_: &mut Session, _: &Value) -> Result<Value> {
/// `path` checked for reading against the automation roots in force.
#[cfg(not(target_arch = "wasm32"))]
fn may_read(path: &str) -> Result<()> {
#[cfg(test)]
touch(path);
crate::file_access::check_read(path).map_err(EngineError::Other)
}
/// Paths read while [`touched`] runs, on any thread (the SVG importer reads on one of its own).
#[cfg(test)]
static TOUCHED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
#[cfg(test)]
static RECORDING: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
fn touch(path: &str) {
if RECORDING.load(std::sync::atomic::Ordering::SeqCst) > 0 {
TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_string());
}
}
/// Run `f` and list the paths read through this module meanwhile (by any test running then).
#[cfg(test)]
pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
use std::sync::atomic::Ordering::SeqCst;
let start = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len();
RECORDING.fetch_add(1, SeqCst);
let out = f();
RECORDING.fetch_sub(1, SeqCst);
let seen = TOUCHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(start..).unwrap_or_default().to_vec();
(out, seen)
}
/// `path` checked for writing against the automation roots in force.
#[cfg(not(target_arch = "wasm32"))]
fn may_write(path: &str) -> Result<()> {
@@ -1003,8 +1045,7 @@ fn list_dir(_: &str, _: usize) -> Result<(Vec<Value>, bool)> {
/// keeps one); `None` when there is no file at `path` (or automation may not read it).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
may_read(path).ok()?;
let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?;
let m = file_metadata(path).filter(std::fs::Metadata::is_file)?;
let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
Some((m.len(), modified))
}
@@ -1013,11 +1054,17 @@ pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
/// (and automation may read it).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn file_created(path: &str) -> Option<u64> {
may_read(path).ok()?;
let t = std::fs::metadata(path).ok()?.created().ok()?;
let t = file_metadata(path)?.created().ok()?;
t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)
}
/// The file system's metadata of `path`, following links (when automation may read it).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn file_metadata(path: &str) -> Option<std::fs::Metadata> {
may_read(path).ok()?;
std::fs::metadata(path).ok()
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn file_created(_: &str) -> Option<u64> {
None
@@ -1033,6 +1080,21 @@ pub(crate) fn absolute_path(path: &str) -> String {
std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned())
}
/// The file to open for `stored`, a path or `file:` URL stored in a document (a linked file, an
/// SVG image's href), or why it is not followed. `document` is the path of the document it is
/// stored in, when it has one: a relative path is taken from its folder. Only files on this
/// computer, or on the network share the document is on, are followed (see `craft_docpath`); a
/// path that is still relative (no document) is refused, as the working directory isn't the
/// document's choice. Every stored path reaches the file system through this.
pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result<String, craft_docpath::Refused> {
let document = document.map(absolute_path);
let path = craft_docpath::resolve(stored, document.as_deref().map(std::path::Path::new))?;
if !path.is_absolute() {
return Err(craft_docpath::Refused::Invalid);
}
path.into_os_string().into_string().map_err(|_| craft_docpath::Refused::Invalid)
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn file_stamp(_: &str) -> Option<(u64, Option<u64>)> {
None
+12 -11
View File
@@ -12,7 +12,10 @@
//! (YYYYMMDD), `<time>` (hhmmss), `<YYYY>`, `<MM>`, `<DD>`, `<hh>`, `<mm>`, `<ss>`.
//!
//! A trailing `.ext` (or `.<ext>`, `.<format>`) is dropped: the extension comes from the format.
//! Anything that could start a folder (`/`, `\`, `:`) becomes `-`.
//! The characters Windows does not allow in a file name (`/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`,
//! `*` and control characters) become `-`, in the template's text and in what the tokens put in.
use super::screens::file_part;
/// What a template's tokens stand for, for one file.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -50,11 +53,6 @@ fn token(name: &str, p: &NameParts<'_>) -> Option<String> {
})
}
/// Text with `/`, `\` and `:` turned into `-`, so it can't start a folder.
fn clean(s: &str) -> String {
s.chars().map(|c| if matches!(c, '/' | '\\' | ':') { '-' } else { c }).collect()
}
/// Does `template` place the row's suffix itself (so it isn't added at the end)?
pub fn has_suffix_token(template: &str) -> bool {
template.contains("<suffix>")
@@ -72,12 +70,12 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
let mut out = String::with_capacity(t.len() + 16);
let mut rest = t;
while let Some(c) = rest.chars().next() {
// Bracketed tokens; an unknown one stays as written.
// Bracketed tokens; an unknown one stays as written, with `-` for its brackets.
if c == '<'
&& let Some(end) = rest.find('>')
&& let Some(value) = rest.get(1..end).and_then(|name| token(name, p))
{
out.push_str(&clean(&value));
out.push_str(&value);
rest = rest.get(end + 1..).unwrap_or("");
continue;
}
@@ -94,10 +92,10 @@ pub fn render(template: &str, p: &NameParts<'_>) -> String {
rest = rest.get(pat.len()..).unwrap_or("");
continue;
}
out.push(if matches!(c, '/' | '\\' | ':') { '-' } else { c });
out.push(c);
rest = rest.get(c.len_utf8()..).unwrap_or("");
}
out.trim().to_string()
file_part(out.trim())
}
/// `YYYY-MM-DD` (or `YYYYMMDD`) as `[year, month, day, 0, 0, 0]`; `None` when it isn't a date.
@@ -133,7 +131,7 @@ mod tests {
assert_eq!(render("YYYY-MM-DD YYYY", &p), "2026-10-16 2026");
// Words are left alone: only the date patterns and bracketed tokens are replaced.
assert_eq!(render("SUMMER-DD-MM", &p), "SUMMER-DD-MM");
assert_eq!(render("<unknown>-x", &p), "<unknown>-x");
assert_eq!(render("<unknown>-x", &p), "-unknown--x");
assert_eq!(render("logo.<format>", &p), "logo", "the extension comes from the format");
assert!(has_suffix_token("a<suffix>") && !has_suffix_token("a"));
}
@@ -143,6 +141,9 @@ mod tests {
let p = NameParts { name: "../../etc", ..parts() };
let n = render("a/b\\c:<name>", &p);
assert!(!n.contains('/') && !n.contains('\\') && !n.contains(':'), "{n}");
// Nor any other character Windows does not allow in a file name, typed or put in.
let p = NameParts { name: "<a>\"b|c?d*\u{0}\u{1f}", ..parts() };
assert_eq!(render("<name>|?*\u{1}", &p), "-a--b-c-d-------");
// A long counter is padded, a short run is not cut.
assert_eq!(render("########", &NameParts { counter: 123, ..parts() }), "00000123");
assert_eq!(render("#", &NameParts { counter: 12345, ..parts() }), "12345");
+1 -1
View File
@@ -103,7 +103,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
name: "aiVersion",
ty: "string",
default: "\"pdf\"",
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque; the other .ai options don't apply)",
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque; the other .ai options don't apply; an error for a .ait template)",
},
FormatOption {
name: "transparentImages",
+6 -2
View File
@@ -235,11 +235,15 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
super::eps::with_flattener(s, cmd, expanded)
}
/// The full export options for `doc`: settings plus the artboards `p` picks (default all).
/// The full export options for `doc`: settings plus the artboards `p` picks (default all), created
/// at `doc`'s File Info creation date (the time of writing when it has none) and modified at the
/// time of writing. On the web, which has no clock, the creation date is the modification date too,
/// and a document without one gets neither.
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, ..Default::default() })
let now = vectorcraft_doc::metadata::now_unix();
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
}
/// Why a PDF of some artboards carries no editing data.
+82
View File
@@ -0,0 +1,82 @@
//! Progress and Cancel of an export running off the UI thread (Background Export), on the shared
//! [`vectorcraft_doc::cancel`] mechanism.
//!
//! The frontend makes a [`Progress`], runs the export inside [`scope`] on its worker and reads the
//! counts from the UI thread to draw them; its Cancel button calls [`Progress::cancel`]. Inside the
//! scope the encoders that work in steps (raster formats: one artboard at a time) report each step
//! through [`vectorcraft_doc::cancel::progress`] and stop at the next check once cancelled; the
//! renderer stops drawing and keeps no half-drawn pattern tile or effect raster. Encoders that
//! make one file in one go report nothing: the frontend shows them as busy and checks
//! [`Progress::cancelled`] itself before writing, so a cancelled export never writes a file.
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use vectorcraft_doc::cancel::{self, CancelToken, ProgressSink};
/// How far an export has got, shared between the worker and the UI thread.
#[derive(Debug, Default)]
pub struct Progress {
token: CancelToken,
done: AtomicU64,
total: AtomicU64,
}
impl Progress {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go).
pub fn counts(&self) -> (u64, u64) {
let total = self.total.load(Ordering::Relaxed);
(self.done.load(Ordering::Relaxed).min(total), total)
}
/// The share of the export done, 0 to 1; `None` while the export can't say.
pub fn fraction(&self) -> Option<f64> {
let (done, total) = self.counts();
(total > 0).then(|| done as f64 / total as f64)
}
/// Ask the export to stop: it writes nothing more, and a render under way stops drawing.
pub fn cancel(&self) {
self.token.cancel();
}
pub fn cancelled(&self) -> bool {
self.token.is_cancelled()
}
}
/// Run `f` with `progress`'s token in effect on this thread and the steps reported to it.
pub fn scope<R>(progress: &Arc<Progress>, f: impl FnOnce() -> R) -> R {
let to = Arc::clone(progress);
let sink: ProgressSink = Rc::new(move |done, total, _| {
to.total.store(total, Ordering::Relaxed);
to.done.store(done, Ordering::Relaxed);
});
cancel::scope(&progress.token, Some(sink), f)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_raster_export_counts_its_artboards_and_stops_once_cancelled() {
let mut doc = vectorcraft_doc::Document::new(100.0, 100.0);
let second = doc.artboards[0].clone();
doc.artboards.push(second);
let p = Progress::new();
assert_eq!(p.fraction(), None, "nothing reported yet");
let png = scope(&p, || super::super::encode_all(&doc, "png", &serde_json::json!({"useArtboards": true})));
assert!(png.is_ok(), "{:?}", png.err());
assert_eq!(p.counts(), (1, 2), "the step under way when the last artboard started");
p.cancel();
let r = scope(&p, || super::super::encode_all(&doc, "png", &serde_json::json!({"useArtboards": true})));
assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{:?}", r.err());
assert!(!cancel::is_cancelled(), "the scope ended");
}
}
+5 -5
View File
@@ -371,7 +371,7 @@ impl<W: std::io::Write> std::io::Write for Counted<W> {
/// `separateArtboards`, those `range` names (default all).
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
// A legacy .ai file is a file per artboard already.
if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, p)? {
if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, f, p)? {
return Ok(vec![]);
}
let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string);
@@ -424,7 +424,7 @@ const NOT_PDF_COMPATIBLE: &str = "saved without PDF content: VectorCraft opens i
/// The shared Save and Export encoder for PDF-compatible `.ai` files (and `.ait` templates, the
/// same file).
pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Encoded> {
if super::legacy_ai::wanted(cmd, p)? {
if super::legacy_ai::wanted(cmd, f, p)? {
return super::legacy_ai::encode(cmd, f, doc, p);
}
let mut params = if p.is_object() { p.clone() } else { json!({}) };
@@ -508,7 +508,7 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
}
let boards = separate_boards(cmd, plan.format, &doc, &params)?;
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
let legacy = super::legacy_ai::wanted(cmd, &params)?;
let legacy = super::legacy_ai::wanted(cmd, plan.format, &params)?;
if let (Some(o), "ai" | "ait") = (params.as_object_mut(), plan.format.id) {
// Save keeps every artboard; exports may choose a subset instead. A legacy .ai file per
// artboard keeps a range given: the artboards to save.
@@ -558,7 +558,7 @@ impl SaveJob {
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params)? } else { encode_all(doc, f.id, &self.params)? };
// A legacy .ai file per artboard.
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, &self.params)?;
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?;
if enc.files.len() != 1 && !per_artboard {
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
}
@@ -797,7 +797,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save as Template…",
["File"],
None,
"{…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}",
"{…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; aiVersion 7 is an error); 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)
),
+191 -70
View File
@@ -18,10 +18,11 @@ use vectorcraft_geom::Rect;
use super::super::*;
use super::encode::single_artboard;
use super::export::{isolated, selected_artwork, without_artboards};
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
use super::naming::{self, NameParts};
use super::{
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, encode, unique_file_names, with_black_output, writable, write_file,
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names, create_dir, distinct_names, encode_with_warnings, unique_file_names,
with_black_output, writable, write_file,
};
const C: &str = "document.exportForScreens";
@@ -148,10 +149,29 @@ fn safe_name(s: &str) -> Option<String> {
(!n.trim_matches('.').is_empty()).then_some(n)
}
/// 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.
/// Text that goes into a file name (prefix, suffix, a data set's name in Batch, a name pattern's text
/// and the names it puts in): the characters Windows does not allow in a file name (`/`, `\`, `:`,
/// `<`, `>`, `"`, `|`, `?`, `*` and the control characters U+0000 to U+001F,
/// <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>) become `-` on every
/// platform, 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()
s.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*') || c < ' ' { '-' } else { c }).collect()
}
/// A whole file or folder name (`[email protected]`, a sub-folder) through [`file_part`], without
/// trailing periods and spaces, and with `-` after a Windows device name that is the whole name or
/// comes before its first period: `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9` and `LPT1` to `LPT9`,
/// also with `¹`, `²` or `³` for the digit, in any case (`CON.png` becomes `CON-.png`). The same on
/// every platform.
pub(crate) fn safe_file_name(s: &str) -> String {
let name = file_part(s);
let name = name.trim_end_matches(['.', ' ']);
let (base, rest) = name.split_at(name.find('.').unwrap_or(name.len()));
let upper = base.to_ascii_uppercase();
let numbered = |n: &str| n.chars().count() == 1 && n.chars().all(|c| matches!(c, '1'..='9' | '¹' | '²' | '³'));
let device = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| upper.strip_prefix("COM").or_else(|| upper.strip_prefix("LPT")).is_some_and(numbered);
if device { format!("{base}-{rest}") } else { name.to_string() }
}
/// Keys of a format row that aren't encoder options.
@@ -322,6 +342,97 @@ impl ScreenParams {
fn of(p: &Value) -> Result<Self> {
if p.is_object() { Self::deserialize(p).map_err(|e| bad(C, e.to_string())) } else { Ok(Self::default()) }
}
/// An error when more than one of `assets`, `layers`, `selection` and `fullDocument` is given.
fn one_source(&self) -> Result<()> {
let sources = [self.full_document, self.assets.is_some(), self.layers.is_some(), self.selection.is_some()].iter().filter(|b| **b).count();
if sources > 1 {
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
}
Ok(())
}
/// `selection`: `None` without it, `Some(false)` for `one` file, `Some(true)` for `each` object.
fn selection_mode(&self) -> Result<Option<bool>> {
match self.selection.as_deref() {
None => Ok(None),
Some("one") => Ok(Some(false)),
Some("each") => Ok(Some(true)),
Some(m) => Err(bad(C, format!("selection `{m}`: one or each"))),
}
}
}
/// How a batch export names its files and sorts them into folders.
struct Naming<'a> {
/// The `name` pattern, when it holds more than spaces.
pattern: Option<&'a str>,
/// The `prefix`, through [`file_part`].
prefix: String,
/// The document's name, safe as a file name (`<filename>`, the Full Document's item).
filename: String,
/// The date and time the pattern's tokens use.
when: [i64; 6],
counter_start: u64,
/// The counter counts files rather than items.
per_file: bool,
folders: Folders,
}
impl<'a> Naming<'a> {
/// The naming of params `o` for a document titled `title`.
fn of(o: &'a ScreenParams, title: &str) -> Result<Self> {
let folders = Folders::of(o)?;
// A prefix names files, not folders.
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
let when = match o.date.as_deref() {
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
};
let per_file = match o.counter_per.as_deref() {
None | Some("item") => false,
Some("file") => true,
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
};
let filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into());
let pattern = o.name.as_deref().filter(|n| !n.trim().is_empty());
Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders })
}
/// The sub-folder (`None` for the export folder itself) and the file name of the file row `r`
/// writes of `item`, the `index`th item (from 0), when `files` files came before it.
fn file(&self, item: &Item, index: usize, files: u64, r: &Row) -> (Option<String>, String) {
let counter = self.counter_start.saturating_add(if self.per_file { files } else { index as u64 });
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
let prefix = &self.prefix;
let stem = match self.pattern {
Some(pattern) => {
let parts = NameParts {
filename: &self.filename,
name: &item.name,
index: index + 1,
counter,
format: ext,
scale: &if r.format.raster { r.size.label() } else { String::new() },
suffix: &r.suffix,
when: self.when,
};
let named = naming::render(pattern, &parts);
let named = if named.is_empty() { item.stem.clone() } else { named };
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
}
None => format!("{prefix}{}{}", item.stem, r.suffix),
};
let file = safe_file_name(&format!("{stem}.{ext}"));
let sub = match self.folders {
Folders::None => None,
Folders::Rows => Some(safe_file_name(&r.folder)),
Folders::Format => Some(ext.to_string()),
Folders::Item => Some(item.stem.clone()),
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
};
(sub, file)
}
}
/// The format rows `p` asks for (`formats`, else `preset`'s, else one PNG at 1x), each with the
@@ -541,40 +652,20 @@ pub(super) fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
/// Every chosen artboard (asset, layer, selected object) in every format row, one file each (a PDF
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
/// so do different files the `name` pattern gives one name.
/// so do different files the `name` pattern gives one name. The reply's `warnings` lists every
/// warning the encoders give for the files, each once.
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let o = ScreenParams::of(p)?;
let rows = rows_of(s, p, o.preset.as_deref())?;
let sources = [o.full_document, o.assets.is_some(), o.layers.is_some(), o.selection.is_some()].iter().filter(|b| **b).count();
if sources > 1 {
return Err(bad(C, "give one of assets, layers, selection or fullDocument"));
}
let folders = Folders::of(&o)?;
// A prefix names files, not folders.
let prefix = file_part(o.prefix.as_deref().unwrap_or(""));
o.one_source()?;
let naming = Naming::of(&o, &s.doc()?.doc.title)?;
let folder = o.folder.as_deref().filter(|f| !f.is_empty());
let when = match o.date.as_deref() {
Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?,
None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil),
};
let per_file = match o.counter_per.as_deref() {
None | Some("item") => false,
Some("file") => true,
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
};
let counter_start = o.counter_start.unwrap_or(1);
let selection = match o.selection.as_deref() {
None => None,
Some("one") => Some(false),
Some("each") => Some(true),
Some(m) => return Err(bad(C, format!("selection `{m}`: one or each"))),
};
let selection = o.selection_mode()?;
let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
if let Some(dir) = folder {
create_dir(dir)?;
}
let doc = s.doc()?.doc.clone();
let filename = safe_name(&super::file_stem(&doc.title)).unwrap_or_else(|| "Untitled".into());
// Include Bleed: each artboard grown by the document's bleed.
let doc = if o.include_bleed && doc.setup.has_bleed() {
let mut d = (*doc).clone();
@@ -586,43 +677,26 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
doc
};
let crop = rows.iter().any(|r| r.format.id != "pdf");
let items = items(&doc, &o, selection, &selected, &filename, crop)?;
let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?;
let mut out = Output::default();
// The encoders' warnings for every file, each once.
let mut warnings: Vec<String> = Vec::new();
// Appearance of Black › Printing / Exporting for RGB raster rows.
let write = |doc: &Document, format: &str, options: &Value| encode(doc, format, &with_black_output(s, options));
let mut write = |doc: &Document, format: &str, options: &Value| -> Result<Vec<u8>> {
let (bytes, notes) = encode_with_warnings(doc, format, &with_black_output(s, options))?;
for w in notes {
if !warnings.contains(&w) {
warnings.push(w);
}
}
Ok(bytes)
};
// A whole-document copy of an artboard, for the formats that write documents to edit.
let mut board_docs: std::collections::HashMap<usize, Arc<Document>> = std::collections::HashMap::new();
let mut files = 0u64;
for (index, item) in items.iter().enumerate() {
for r in &rows {
let counter = counter_start.saturating_add(if per_file { files } else { index as u64 });
let ext = r.format.extensions.first().copied().unwrap_or(r.format.id);
let stem = match o.name.as_deref().filter(|n| !n.trim().is_empty()) {
Some(pattern) => {
let parts = NameParts {
filename: &filename,
name: &item.name,
index: index + 1,
counter,
format: ext,
scale: &if r.format.raster { r.size.label() } else { String::new() },
suffix: &r.suffix,
when,
};
let named = naming::render(pattern, &parts);
let named = if named.is_empty() { item.stem.clone() } else { named };
if naming::has_suffix_token(pattern) { format!("{prefix}{named}") } else { format!("{prefix}{named}{}", r.suffix) }
}
None => format!("{prefix}{}{}", item.stem, r.suffix),
};
let file = format!("{stem}.{ext}");
let sub = match folders {
Folders::None => None,
Folders::Rows => Some(r.folder.clone()),
Folders::Format => Some(ext.to_string()),
Folders::Item => Some(item.stem.clone()),
Folders::ItemFormat => Some(format!("{}/{ext}", item.stem)),
};
let (sub, file) = naming.file(item, index, files, r);
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
@@ -644,34 +718,80 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
if o.zip {
let zip = super::zip::store(&out.kept).map_err(|e| bad(C, e))?;
let zip_name = format!("{prefix}{filename}.zip");
let zip_name = safe_file_name(&format!("{}{}.zip", naming.prefix, naming.filename));
return match folder {
Some(dir) => {
let path = format!("{dir}/{zip_name}");
write_file(&path, &zip)?;
Ok(json!({ "path": path, "bytes": zip.len(), "files": names }))
Ok(json!({ "path": path, "bytes": zip.len(), "files": names, "warnings": warnings }))
}
None => Ok(json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names })),
None => Ok(
json!({ "name": zip_name, "dataBase64": vectorcraft_format::base64_encode(&zip), "bytes": zip.len(), "files": names, "warnings": warnings }),
),
};
}
Ok(match folder {
Some(_) => json!({ "files": out.written }),
Some(_) => json!({ "files": out.written, "warnings": warnings }),
None => {
json!({ "files": out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect::<Vec<_>>() })
let files: Vec<Value> =
out.kept.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
json!({ "files": files, "warnings": warnings })
}
})
}
/// The path, relative to the export folder, of the first file `document.exportForScreens` with
/// params `p` writes (sub-folders included: `2x/[email protected]`): the first format row's file of the
/// first artboard, asset, layer or selected object, or of the whole document, named as [`export`]
/// names it. It encodes and writes nothing and cuts no art out of the document: of the objects
/// selected for a file each, it measures only the first ones, up to the first that draws something.
/// It does not refuse what the export refuses for drawing nothing: a checked asset or layer, the
/// selection as one file, or the Full Document with no visible art.
/// The Export for Screens dialog shows it on its Example line.
pub fn first_file(s: &Session, p: &Value) -> Result<String> {
let p = with_preset(s, p)?;
let o = ScreenParams::of(&p)?;
let rows = rows_of(s, &p, o.preset.as_deref())?;
o.one_source()?;
let selection = o.selection_mode()?;
let doc = &s.doc()?.doc;
let mut selected = if selection.is_some() { selected_artwork(s)? } else { vec![] };
// A file per object: objects that draw nothing write no file.
if selection == Some(true) {
selected = selected.into_iter().find(|id| export_crop(doc, &[*id]).is_some()).into_iter().collect();
}
let naming = Naming::of(&o, &doc.title)?;
let items = items(doc, &o, selection, &selected, &naming.filename, false, false)?;
let (Some(item), Some(r)) = (items.first(), rows.first()) else { return Err(bad(C, "nothing to export")) };
let (sub, file) = naming.file(item, 0, 0, r);
Ok(sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}")))
}
/// What `o` exports a file of per row ([`Item`]): the whole document, the assets, the layers, the
/// selection (one item, or one per object), else the chosen artboards.
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF).
fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selected: &[NodeId], filename: &str, crop: bool) -> Result<Vec<Item>> {
/// `crop`: some row needs the Full Document's art bounds (a format other than PDF). `art`: each
/// asset, layer and selected object is cut out of the document; without it, only the items' names
/// are made, every item holds the whole document, and none is left out for drawing nothing.
fn items(
doc: &Arc<Document>,
o: &ScreenParams,
selection: Option<bool>,
selected: &[NodeId],
filename: &str,
crop: bool,
art: bool,
) -> Result<Vec<Item>> {
// The art of `ids` alone, as an item named `name`.
let alone = |ids: &[NodeId], name: &str| -> Option<(Arc<Document>, Rect)> {
if !art {
return Some((doc.clone(), Rect::ZERO));
}
isolated(doc, &doc.paint_order(ids.iter().copied()), name).map(|(d, r)| (Arc::new(d), r))
};
// Each item's stem follows Windows' file-naming rules, and no two items share one.
let safe = |stems: Vec<String>| distinct_names(stems.iter().map(|s| safe_file_name(s)));
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
let stems = unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string())));
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
pieces
.into_iter()
.zip(stems)
@@ -688,7 +808,8 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
(doc.clone(), Rect::ZERO)
};
let name = filename.to_string();
return Ok(vec![Item { name: name.clone(), stem: name, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
let stem = safe_file_name(&name);
return Ok(vec![Item { name, stem, doc: whole, artboard: Some(0), region, every_artboard: Some(doc.clone()) }]);
}
if let Some(ids) = &o.assets {
let mut pieces = vec![];
@@ -742,7 +863,7 @@ fn items(doc: &Arc<Document>, o: &ScreenParams, selection: Option<bool>, selecte
if boards.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
let stems = artboard_file_names(doc, &boards);
let stems = safe(artboard_file_names(doc, &boards));
Ok(boards
.into_iter()
.zip(stems)
+107
View File
@@ -208,6 +208,113 @@ fn planar_cmyk_tiffs_open_and_place_with_their_inks() {
assert!(image::load_from_memory(&blob.bytes).is_ok(), "the image decoder reads the stored image");
}
/// A 70 × 7 picture, `true` for black: a white row and a black row (runs past 63 take a make-up
/// code), then runs of several lengths.
fn fax_picture() -> Vec<Vec<bool>> {
(0..7).map(|y| (0..70).map(|x| if y < 2 { y == 1 } else { (x * (y + 2) / 5 + y) % 3 == 0 }).collect()).collect()
}
/// The first `rows` rows of [`fax_picture`] as a TIFF compressed with CCITT Group 3 in one strip:
/// `compression` 2 or 3 with `t4_options`, white is zero, the highest bit of each byte first.
fn group3_tiff(compression: u16, t4_options: u32, rows: usize) -> Vec<u8> {
let rows = &fax_picture()[..rows];
let g =
vectorcraft_testkit::tiff::Group3 { rows, compression, t4_options, photometric: 0, fill_order: 1, rows_per_strip: 7, tile: None, rtc: false };
vectorcraft_testkit::tiff::group3(&g)
}
/// 1-bit TIFFs compressed with CCITT Group 3 open and place with their pixels (#1466): Compression
/// 2 (modified Huffman) and Compression 3 (ITU-T T.4) with one-dimensional rows or two-dimensional
/// ones (T4Options bit 0), with and without fill bits (bit 2) and six EOLs at the end, in one strip,
/// three strips or three 32 × 16 tiles, each byte's highest or lowest bit first (FillOrder 1 and 2),
/// white or black as zero.
#[test]
fn ccitt_group3_tiffs_open_and_place_with_their_pixels() {
let rows = fax_picture();
let mut s = Session::new();
for (compression, t4_options, rtc) in [(2, 0, false), (3, 0, false), (3, 4, true), (3, 1, false), (3, 5, true)] {
for (rows_per_strip, tile) in [(7, None), (3, None), (7, Some((32, 16)))] {
for fill_order in [1, 2] {
for photometric in [0, 1] {
let what = format!(
"Compression {compression}, T4Options {t4_options}, {rows_per_strip} rows a strip, tiles {tile:?}, FillOrder {fill_order}, photometric {photometric}"
);
let g = vectorcraft_testkit::tiff::Group3 {
rows: &rows,
compression,
t4_options,
photometric,
fill_order,
rows_per_strip,
tile,
rtc,
};
let file = vectorcraft_testkit::tiff::group3(&g);
assert!(image::load_from_memory(&file).is_err(), "{what}: the image decoder reads Group 3 now");
// A black run's pixels are 1 bits, which show as black when white is zero.
let want: Vec<u8> = rows.concat().iter().map(|&black| if black == (photometric == 0) { 0 } else { 255 }).collect();
let pixels = |s: &Session| {
let blob = &s.doc().unwrap().doc.images[&image_of(s).key];
image::load_from_memory(&blob.bytes).unwrap_or_else(|e| panic!("{what}: {e}")).to_luma8().into_raw()
};
open(&mut s, "fax.tif", &file);
assert_eq!(pixels(&s), want, "{what}: Open");
let mut placed = session(100.0, 100.0, 1);
placed.execute("file.place", &json!({"name": "fax.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
assert_eq!(pixels(&placed), want, "{what}: Place");
}
}
}
}
}
/// A Group 3 TIFF whose pixels would take more than the decoding allowance as RGBA is refused as
/// too large to draw, without a panic at 2^31 × 2^31 pixels too. One whose data ends inside a row,
/// or after a whole row before the strip's last, is refused as damaged, and so is one whose tiles
/// are wider than the allowance.
#[test]
fn ccitt_group3_tiffs_too_large_or_cut_short_are_refused() {
// ImageWidth (the directory's first entry, its value at offset 18), ImageLength (the second, at
// 30) and RowsPerStrip (the eighth, at 102).
for (width, height) in [(70u32, 1u32 << 30), (1 << 31, 1 << 31)] {
let mut big = group3_tiff(3, 1, 7);
big[18..22].copy_from_slice(&width.to_le_bytes());
big[30..34].copy_from_slice(&height.to_le_bytes());
big[102..106].copy_from_slice(&height.to_le_bytes());
let e = raster_image(&big).err().expect("refused");
assert!(e.to_string().contains(&format!("an image of {width} × {height} pixels is too large to draw")), "{e}");
}
let mut short = group3_tiff(3, 0, 7);
short.truncate(short.len() - 6);
let e = raster_image(&short).err().expect("refused");
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
// Compression 2 rows start at a byte, so the strip of the first six rows is where the strip of
// seven starts. StripByteCounts (the ninth entry, its value at offset 114) gives the six rows'.
let (mut seven, six) = (group3_tiff(2, 0, 7), group3_tiff(2, 0, 6));
seven[114..118].copy_from_slice(&six[114..118]);
assert_eq!(seven[30..34], 7u32.to_le_bytes(), "ImageLength");
assert_eq!(seven[78..82], six[78..82], "StripOffsets (the sixth entry, at offset 78)");
let e = raster_image(&seven).err().expect("refused");
assert!(e.to_string().contains("could not decode image: unexpected end of input"), "{e}");
// A tile wider than the allowance: TileWidth (the eighth entry, its value at offset 102).
let rows = fax_picture();
let g = vectorcraft_testkit::tiff::Group3 {
rows: &rows,
compression: 3,
t4_options: 1,
photometric: 0,
fill_order: 1,
rows_per_strip: 7,
tile: Some((80, 16)),
rtc: false,
};
let mut wide = vectorcraft_testkit::tiff::group3(&g);
assert_eq!(wide[102..106], 80u32.to_le_bytes(), "TileWidth");
wide[102..106].copy_from_slice(&u32::MAX.to_le_bytes());
let e = raster_image(&wide).err().expect("refused");
assert!(e.to_string().contains("could not decode image: arithmetic overflow"), "{e}");
}
#[test]
fn content_beats_a_wrong_extension() {
let png = image_bytes(2, 2, image::ImageFormat::Png);
+58 -4
View File
@@ -135,16 +135,42 @@ fn a_pdfs_art_past_the_page_opens_whole_from_its_editing_data() {
assert!(s.doc().unwrap().doc.art_bounds().is_some_and(|b| b.x1 < 200.5));
}
/// Editing data that can't be read leaves the file as its PDF part reads, with that part's notes:
/// a `.ai`'s art outside its artboards is missing, and a PDF's art past the page is clipped.
/// A `.ai` or a PDF whose editing data can't be decompressed opens from its PDF part with a note
/// that its layers weren't read, and an export to its own path is refused unless it is given
/// `acknowledgeLoss`.
#[test]
fn a_file_whose_editing_data_cant_be_decompressed_is_not_written_over_unasked() {
let d = std::env::temp_dir().join(format!("vc-aiundecoded-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let damaged = b"%AI12_CompressedDataxx";
for (name, file) in [("art.ai", ai::ai(damaged, ai::page_pdf())), ("art.pdf", ai::pdf(damaged, ai::page_pdf()))] {
let path = d.join(name);
std::fs::write(&path, &file).unwrap();
let path = path.to_string_lossy().to_string();
let mut s = Session::new();
let r = s.execute("document.open", &json!({"path": path})).unwrap();
assert!(r["warnings"].to_string().contains("layers weren't read from its editing data (its zlib compression is damaged)"), "{name}: {r}");
let e = s.execute("document.export", &json!({"path": path})).unwrap_err().to_string();
assert!(e.contains("left things out") && e.contains("layers weren't read"), "{name}: {e}");
assert_eq!(std::fs::read(&path).unwrap(), file, "{name}");
s.execute("document.export", &json!({"path": path, "acknowledgeLoss": true})).unwrap();
assert_ne!(std::fs::read(&path).unwrap(), file, "{name}");
}
std::fs::remove_dir_all(d).ok();
}
/// Editing data that can't be read leaves the file as its PDF part reads, with that part's notes
/// and then a note saying why its layers weren't read: a `.ai`'s art outside its artboards is
/// missing, and a PDF's art past the page is clipped.
#[test]
fn editing_data_that_cant_be_read_keeps_the_pdf_parts_notes() {
let damaged = b"%AI12_CompressedData not zlib";
let unread = "the file's layers weren't read from its editing data (its zlib compression is damaged): they come from its PDF part";
let mut s = Session::new();
let r = open(&mut s, "damaged.ai", &ai::ai(damaged, ai::page_pdf()), json!({})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::OFF_ARTBOARD_NOTE]), "{r}");
assert_eq!(r["warnings"], json!([vectorcraft_pdf::OFF_ARTBOARD_NOTE, unread]), "{r}");
let r = open(&mut s, "damaged.pdf", &ai::pdf(damaged, "0 g 150 20 100 60 re f"), json!({})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE]), "{r}");
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE, unread]), "{r}");
}
#[test]
@@ -158,6 +184,34 @@ fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}");
}
/// A `.ai` whose document data has a variable library opens with a note that its variables and
/// data sets weren't read, whether its layers are read or it comes in as its PDF part.
#[test]
fn an_ai_file_with_a_variable_library_says_its_variables_werent_read() {
const NOTE: &str = "the file's variables and data sets weren't read (variables: 2, data sets: 3): the Variables panel doesn't list them";
let library = ai::variable_library(&["Name", "Number"], &["One", "Two", "Three"]);
let square = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0));
let notes =
|r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("variables")).cloned().collect::<Vec<_>>();
let mut s = Session::new();
let layered = ai::editing_data(200.0, 100.0, &format!("{library}{}", ai::layer("Art", &square)));
let file = ai::ai(&compressed(layered.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
let r = open(&mut s, "variables.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(notes(&r), [json!(NOTE)], "{r}");
let r = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(r["variables"], json!([]), "{r}");
// Its layers can't be read (a symbol): it comes in as its PDF part, with the note.
let symbols = ai::editing_data(200.0, 100.0, &format!("{library}{}", ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")));
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
assert!(r["warnings"].to_string().contains("PDF part"), "{r}");
assert_eq!(notes(&r), [json!(NOTE)], "{r}");
// Without a library, no note.
let plain = ai::editing_data(200.0, 100.0, &ai::layer("Art", &square));
let r = open(&mut s, "plain.ai", &ai::ai(&compressed(plain.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
assert!(notes(&r).is_empty(), "{r}");
}
/// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has.
const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET";
@@ -105,6 +105,17 @@ fn the_counter_counts_items_or_files_from_any_start() {
assert_eq!(parts, ["1_Artboard 2_2x@2x_png.png"]);
}
/// Export for Screens writes the characters Windows does not allow in a file name (`<`, `>`, `"`,
/// `|`, `?`, `*`, control characters) as `-` in a pattern's text and in the names its tokens stand
/// for, on every platform.
#[test]
fn a_pattern_writes_the_characters_windows_does_not_allow_as_dashes() {
let mut s = session(1);
s.execute("artboard.setProps", &json!({"index": 0, "name": "a|b?\"c\u{7}"})).unwrap();
let r = export(&mut s, json!({"name": "<name>*<x>", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["a-b--c---x-.svg"]);
}
#[test]
fn layers_and_the_selection_are_exported_by_name() {
let mut s = session(1);
+50
View File
@@ -131,6 +131,56 @@ fn options_not_applied_yet_and_document_features_warn() {
assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty());
}
/// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes,
/// and the rest of the page is written. Each reply that carries the PDF's warnings has the note
/// raster exports give, once.
#[test]
fn every_pdf_leaves_out_an_image_that_cant_be_decoded() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let mut s = session(1);
let cut = crate::tests_menucmds::place_undecodable_png(&mut s);
// Whether each object on the PDF's pages is an image. The pages should hold the rectangle and
// no image.
let drawn = |bytes: &[u8]| {
let mut images = vec![];
vectorcraft_pdf::import(bytes).unwrap().walk(|n| {
if !n.is_container() && !matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: true, .. }) {
images.push(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)));
}
});
images
};
for (cmd, p) in [
("document.exportPdf", json!({})),
("document.exportPdf", json!({"preset": "High Quality Print"})),
("document.exportPdf", json!({"standard": "pdfX1a"})),
("document.export", json!({"format": "pdf"})),
("file.saveCopy", json!({"format": "pdf"})),
("file.saveCopy", json!({"format": "ai"})),
("file.print", json!({})),
] {
let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
assert_eq!(warnings(&v).iter().filter(|w| *w == NOTE).count(), 1, "{cmd} {p}: {}", v["warnings"]);
assert_eq!(drawn(&b64(&v)), [false], "{cmd} {p}");
}
// The .ai file reopens with the image as it was placed, from the image bytes in its editing data.
let ai = b64(&s.execute("file.saveCopy", &json!({"format": "ai"})).unwrap());
let placed = s.doc().unwrap().doc.images.clone();
s.execute("document.open", &json!({"name": "cut.ai", "dataBase64": vectorcraft_format::base64_encode(&ai)})).unwrap();
let reopened = &s.doc().unwrap().doc;
assert!(reopened.node(cut).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))));
assert_eq!(reopened.images, placed);
// Export for Screens, a native file carrying a PDF, and the PDF that Copy offers are written.
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "pdf"}]})).unwrap();
assert_eq!(drawn(&b64(&r["files"][0])), [false]);
assert!(s.execute("file.saveCopy", &json!({"format": "vectorcraft", "pdfCompatible": true})).is_ok());
s.execute("select.set", &json!({"ids": [cut.0]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
assert!(b64(&s.execute("clipboard.exportPdf", &json!({})).unwrap()).starts_with(b"%PDF"));
s.execute("prefs.set", &json!({"key": "copyAsPdf", "value": true})).unwrap();
assert!(s.clipboard_flavours().iter().any(|f| f.mime == crate::cmd::clipboard::PDF));
}
#[test]
fn export_pdf_writes_a_path() {
let mut s = session(2);
@@ -232,6 +232,52 @@ fn save_as_template_names_its_file_after_the_template_format() {
}
}
/// Exports don't write a `.ait`: each export asked for one, by its path or `format`, answers an
/// error that names `file.saveAsTemplate`, which writes it, and writes nothing. A format that
/// nothing writes keeps its own error.
#[test]
fn exports_of_an_ait_name_save_as_template() {
let mut s = rich();
let path = tmp("exported.ait");
let _ = std::fs::remove_file(&path);
for (cmd, p) in [
("document.export", json!({"path": path})),
("document.export", json!({"format": "ait"})),
("document.serialize", json!({"format": "ait"})),
("document.exportSelection", json!({"path": path})),
("document.exportForScreens", json!({"formats": [{"format": "ait"}]})),
] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("PDF-compatible .ait template files are written by file.saveAsTemplate"), "{cmd} {p}: {e}");
}
assert!(!std::path::Path::new(&path).exists(), "nothing written");
assert_eq!(writable_format(Some("ps"), None).unwrap_err(), "PostScript files can be opened but not written (see document.formats)");
}
/// Save as Template refuses `aiVersion` 7 for a `.ait`, given in `options` or flat, and writes no
/// file; `aiVersion` "pdf" writes the PDF-compatible template.
#[test]
fn save_as_template_refuses_a_legacy_ait() {
let mut s = rich();
let path = tmp("legacy-ait/card.ait");
let dir = std::path::Path::new(&path).parent().unwrap().to_path_buf();
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for p in [
json!({"path": path, "options": {"aiVersion": "7"}}),
json!({"path": path, "format": "ait", "aiVersion": 7}),
json!({"format": "ait", "options": {"aiVersion": "7"}}),
] {
let e = s.execute("file.saveAsTemplate", &p).unwrap_err().to_string();
assert!(e.contains("aiVersion 7 writes legacy .ai files"), "{p}: {e}");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "nothing written");
let r = s.execute("file.saveAsTemplate", &json!({"path": path, "options": {"aiVersion": "pdf"}})).unwrap();
assert_eq!(r["format"], "ait", "{r}");
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"%PDF") && vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "PDF-compatible, carrying the document");
}
#[test]
fn standards_and_screens_leave_the_editing_data_out() {
let mut s = rich();
@@ -59,6 +59,23 @@ fn layers_option_keeps_or_flattens_optional_content() {
assert!(open(&mut s, &bytes, json!({"layers": "no"})).unwrap_err().to_string().contains("layers must be true or false"));
}
/// A PDF-compatible `.ai` without the editor's private data opens its layers from its optional
/// content groups, whose names without a byte order mark are PDFDocEncoding.
#[test]
fn ai_layer_names_are_read_as_pdf_doc_encoding() {
let (a, b) = (first_extra(1), first_extra(1) + 1);
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << >> >> ");
let page = PdfPage {
resources: format!("/Properties << /A {a} 0 R /B {b} 0 R >>"),
..PdfPage::new(100.0, 100.0, "/OC /A BDC 0 g 0 0 10 10 re f EMC /OC /B BDC 0 g 20 20 10 10 re f EMC")
};
let bytes = pdf_with_catalog(&[page], &[r"<< /Type /OCG /Name (Art\205final) >>", "<< /Type /OCG /Name (Notes) >>"], &catalog, None);
let mut s = Session::new();
s.execute("document.open", &json!({"name": "art.ai", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
let names: Vec<String> = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Art\u{2013}final", "Notes"]);
}
#[test]
fn load_options_read_text_and_layers() {
let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines", "layers": false})).unwrap();
@@ -110,6 +110,99 @@ fn density_buckets_go_into_sub_folders() {
assert_eq!(names(&r), ["Artboard-2-..-x-..-y.svg", "Artboard-2-ok.png"]);
}
/// Windows does not allow `<`, `>`, `"`, `|`, `?`, `*` or control characters in a file name
/// (<https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>): Export for Screens
/// writes them as `-` in a prefix and a suffix, on every platform.
#[test]
fn a_prefix_or_suffix_writes_the_characters_windows_does_not_allow_as_dashes() {
let mut s = session(1);
let r = export(&mut s, json!({"prefix": "a<b>c\"", "formats": [{"format": "svg", "suffix": "|d?e*f\u{1}\t"}]}));
assert_eq!(names(&r), ["a-b-c-Artboard-1-d-e-f--.svg"]);
}
/// A file or folder named after a Windows device (`CON`, `NUL`, `COM1`…, alone or before an
/// extension) gets a `-` after the device name, and a folder loses a trailing period, on every
/// platform.
#[test]
fn device_names_and_trailing_periods_follow_windows_naming_rules() {
let mut s = session(3);
for (index, name) in ["CON", "CON-", "lpt1"].iter().enumerate() {
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
}
let r = export(&mut s, json!({"formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["CON-.svg", "CON--2.svg", "lpt1-.svg"]);
let r =
export(&mut s, json!({"range": "1", "subfolders": true, "formats": [{"format": "svg", "folder": "aux"}, {"format": "pdf", "folder": "x."}]}));
assert_eq!(names(&r), ["aux-/CON-.svg", "x/CON-.pdf"]);
let r = export(&mut s, json!({"range": "3", "folders": "item", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["lpt1-/lpt1-.svg"]);
// A name pattern, with an extension after the device name, and the document's own name.
let r = export(&mut s, json!({"range": "1", "name": "<name>.tar", "formats": [{"format": "svg"}]}));
assert_eq!(names(&r), ["CON-.tar.svg"]);
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap();
let r = export(&mut s, json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}));
assert_eq!(names(&r), ["nul-/nul-.pdf"]);
let r = s.execute("document.exportForScreens", &json!({"zip": true, "formats": [{"format": "svg"}]})).unwrap();
assert_eq!(r["name"], "nul-.zip");
}
/// `first_file` names the file the export writes first, for the same params: the item's file name,
/// a prefix and a suffix with characters Windows does not allow, a range, a Windows device name,
/// every kind of folder, presets, size suffixes, a name pattern with its counter and date, an
/// export preset, a ZIP, Full Document, Include Bleed, assets, layers and the selection; and it
/// refuses what the export refuses.
#[test]
fn the_first_file_is_the_file_the_export_writes_first() {
let mut s = session(3);
s.execute("artboard.setProps", &json!({"index": 1, "name": "CON"})).unwrap();
let layer = s.doc().unwrap().doc.layers[0].id.0;
let square = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
let asset = s.execute("assets.add", &json!({"ids": [square], "name": "Logo?"})).unwrap()["assets"][0].as_u64().unwrap();
s.execute("shape.ellipse", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
s.execute("select.all", &json!({})).unwrap();
let written = |r: &Value| r["files"][0].as_str().or_else(|| r["files"][0]["name"].as_str()).unwrap_or_default().to_string();
let cases = [
json!({}),
json!({"prefix": "a?b/", "formats": [{"format": "png", "suffix": "<x>"}]}),
json!({"range": "2-3", "folders": "item"}),
json!({"range": "2", "folders": "itemFormat", "formats": [{"format": "svg", "scale": "2x", "suffix": "@2x"}]}),
json!({"preset": "mobile"}),
json!({"preset": "density"}),
json!({"subfolders": true, "formats": [{"format": "png", "scale": "144ppi", "folder": "a b"}]}),
json!({"folders": "format", "formats": [{"format": "jpg", "quality": 50}]}),
json!({
"name": "<filename>-<name>-<scale>-<index>-###-YYYYMMDD",
"counterStart": 7,
"counterPer": "file",
"date": "2026-10-17",
"formats": [{"format": "png", "scale": "2x"}]
}),
json!({"name": "<suffix>", "formats": [{"format": "png", "suffix": ""}]}),
json!({"exportPreset": "Logo Package"}),
json!({"zip": true, "formats": [{"format": "svg"}, {"format": "png"}]}),
json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}),
json!({"fullDocument": true, "formats": [{"format": "png"}]}),
json!({"includeBleed": true}),
json!({"assets": [asset], "formats": [{"format": "svg"}]}),
json!({"layers": [layer], "folders": "item"}),
json!({"selection": "one"}),
json!({"selection": "each", "name": "<name>"}),
];
for p in cases {
let first = super::screens::first_file(&s, &p).unwrap();
assert_eq!(first, written(&export(&mut s, p.clone())), "{p}");
}
for p in [json!({"layers": [layer], "assets": [asset]}), json!({"selection": "all"}), json!({"counterPer": "row"})] {
assert!(super::screens::first_file(&s, &p).is_err() && s.execute("document.exportForScreens", &p).is_err(), "{p}");
}
// A document named after a Windows device.
s.execute("file.new", &json!({"width": 40, "height": 30, "name": "nul"})).unwrap();
for p in [json!({"fullDocument": true, "folders": "item", "formats": [{"format": "pdf"}]}), json!({"name": "<filename>"})] {
let first = super::screens::first_file(&s, &p).unwrap();
assert_eq!(first, written(&export(&mut s, p.clone())), "{p}");
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn sub_folders_are_made_in_the_export_folder() {
@@ -164,6 +257,45 @@ fn a_zip_is_written_into_the_folder() {
let _ = std::fs::remove_dir_all(dir);
}
/// The warnings in `r` that contain `text`.
fn warned<'a>(r: &'a Value, text: &str) -> Vec<&'a str> {
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains(text)).collect()
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn every_reply_says_once_when_an_image_could_not_be_decoded() {
let mut s = session(1);
let rows = json!([{"format": "png"}, {"format": "jpg", "scale": "2x"}]);
assert_eq!(export(&mut s, json!({ "formats": rows }))["warnings"], json!([]));
// The renderer can't decode the placed image and draws nothing in its place.
crate::tests_menucmds::place_undecodable_png(&mut s);
let dir = std::env::temp_dir().join(format!("vc-screens-undecoded-{}", std::process::id()));
let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/");
for p in [
json!({ "formats": rows }),
json!({ "formats": rows, "zip": true }),
json!({ "formats": rows, "folder": folder }),
json!({ "formats": rows, "folder": folder, "zip": true }),
json!({ "formats": rows, "fullDocument": true }),
] {
let r = export(&mut s, p.clone());
assert_eq!(warned(&r, "couldn't be decoded").len(), 1, "{p}: {}", r["warnings"]);
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "the files are still written");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn type_in_a_missing_family_is_named_once_for_every_format() {
let mut s = session(2);
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
let r = export(&mut s, json!({"formats": [{"format": "png"}, {"format": "pdf"}, {"format": "eps"}]}));
let fallback = warned(&r, "fallback font");
assert_eq!(fallback.len(), 1, "{}", r["warnings"]);
assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}");
}
#[test]
fn full_document_gives_one_file_per_format() {
let mut s = session(3);
@@ -85,3 +85,19 @@ fn save_for_office_writes_a_png_at_the_resolution() {
let spec = crate::cmd::find_command("document.exportForOffice").unwrap();
assert_eq!((spec.label, spec.menu), ("Save for Office Documents…", &["File"][..]));
}
#[test]
fn save_for_office_says_when_an_image_or_a_font_is_missing() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
assert_eq!(s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap()["warnings"], json!([]));
// The renderer can't decode the placed image, and the type's family isn't installed.
crate::tests_menucmds::place_undecodable_png(&mut s);
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
let r = s.execute("document.exportForOffice", &json!({"ppi": 72})).unwrap();
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings.contains(&"an image couldn't be decoded and is missing from the picture"), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("fallback font") && w.contains("Nonexistent Sans Probe")), "{warnings:?}");
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(200), Some(100)), "the PNG is still written");
}
+144
View File
@@ -1,5 +1,10 @@
//! A store-only (uncompressed) ZIP archive: several exported files as one download. The files are
//! already compressed (PNG, JPEG, PDF…), so storing them keeps the archive fast to write and read.
//!
//! [`Archive`] reads the files of a ZIP archive, stored or deflated, as PKWARE's APPNOTE.TXT
//! describes the format (<https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT>).
use std::io::Read as _;
use flate2::Crc;
@@ -65,6 +70,145 @@ pub fn store<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result<Vec<u8>,
Ok(out)
}
/// Does `bytes` start with a ZIP archive's first local file header?
pub fn is_zip(bytes: &[u8]) -> bool {
bytes.starts_with(&0x0403_4b50u32.to_le_bytes())
}
/// Why [`Archive`] can't read an archive or one of its files.
#[derive(Debug, PartialEq)]
pub enum ReadError {
/// Not a ZIP archive: no end of central directory record.
NotZip,
/// More files than the reader was asked to accept.
TooManyFiles,
/// The archive needs the 64-bit (ZIP64) records, which the reader does not read.
Zip64,
/// The file is encrypted.
Encrypted(String),
/// The file is compressed with a method other than stored (0) or deflated (8).
Method(String, u16),
/// The file unpacks to more bytes than the reader was asked to accept.
TooLarge(String),
/// The archive (empty name) or the file is truncated, inconsistent or fails its CRC.
Damaged(String),
}
/// One file of an archive, as its central directory record describes it.
struct Entry {
name: String,
flags: u16,
method: u16,
crc: u32,
packed: usize,
size: usize,
local: usize,
}
/// A ZIP archive's files, listed from its central directory.
pub struct Archive<'a> {
zip: &'a [u8],
entries: Vec<Entry>,
}
impl<'a> Archive<'a> {
/// The archive `zip`, with at most `max_files` files.
pub fn open(zip: &'a [u8], max_files: usize) -> Result<Self, ReadError> {
let damaged = || ReadError::Damaged(String::new());
let u16_at = |at: usize| at.checked_add(2).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
let u32_at = |at: usize| at.checked_add(4).and_then(|end| zip.get(at..end)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
// The end record is 22 bytes followed by a comment of at most 65535 bytes: the signature
// whose comment ends the archive, else the last signature (the archive has bytes after it).
let last = zip.len().checked_sub(22).ok_or(ReadError::NotZip)?;
let candidates = || (last.saturating_sub(0xFFFF)..=last).rev().filter(|&at| u32_at(at) == Some(0x0605_4b50));
let end =
candidates().find(|&at| u16_at(at + 20).map(usize::from) == Some(last - at)).or_else(|| candidates().next()).ok_or(ReadError::NotZip)?;
let count = u16_at(end + 10).ok_or_else(damaged)?;
let start = u32_at(end + 16).ok_or_else(damaged)?;
if count == 0xFFFF || start == 0xFFFF_FFFF || u32_at(end + 12) == Some(0xFFFF_FFFF) {
return Err(ReadError::Zip64);
}
let count = usize::from(count);
if count > max_files {
return Err(ReadError::TooManyFiles);
}
let mut at = start as usize;
let mut entries = Vec::with_capacity(count);
for _ in 0..count {
if u32_at(at) != Some(0x0201_4b50) {
return Err(damaged());
}
let short = |off: usize| at.checked_add(off).and_then(u16_at).ok_or_else(damaged);
let long = |off: usize| at.checked_add(off).and_then(u32_at).ok_or_else(damaged);
let (name_len, extra, comment) = (usize::from(short(28)?), usize::from(short(30)?), usize::from(short(32)?));
let name_at = at.checked_add(46).ok_or_else(damaged)?;
let name = name_at.checked_add(name_len).and_then(|end| zip.get(name_at..end)).ok_or_else(damaged)?;
entries.push(Entry {
name: String::from_utf8_lossy(name).into_owned(),
flags: short(8)?,
method: short(10)?,
crc: long(16)?,
packed: long(20)? as usize,
size: long(24)? as usize,
local: long(42)? as usize,
});
at = name_at.checked_add(name_len + extra + comment).ok_or_else(damaged)?;
}
Ok(Self { zip, entries })
}
/// The file `name`, matched in any ASCII case.
fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.name.eq_ignore_ascii_case(name))
}
/// Does the archive hold the file `name` (in any ASCII case)?
pub fn contains(&self, name: &str) -> bool {
self.entry(name).is_some()
}
/// The bytes of the file `name` (matched in any ASCII case; None: the archive has no such
/// file), when it unpacks to at most `max` bytes and passes its CRC.
pub fn read(&self, name: &str, max: usize) -> Result<Option<Vec<u8>>, ReadError> {
let Some(e) = self.entry(name) else { return Ok(None) };
let damaged = || ReadError::Damaged(e.name.clone());
if e.flags & 1 != 0 {
return Err(ReadError::Encrypted(e.name.clone()));
}
if e.size > max {
return Err(ReadError::TooLarge(e.name.clone()));
}
// The local header: its signature, then its name and extra field lengths at 26 and 28.
let header = e.local.checked_add(30).and_then(|end| self.zip.get(e.local..end)).ok_or_else(damaged)?;
let (Some(sig), Some(name_len), Some(extra)) = (header.get(..4), header.get(26..28), header.get(28..30)) else { return Err(damaged()) };
if sig != 0x0403_4b50u32.to_le_bytes() {
return Err(damaged());
}
let len = |b: &[u8]| b.try_into().map(|b| usize::from(u16::from_le_bytes(b))).map_err(|_| damaged());
let start = e.local + 30 + len(name_len)? + len(extra)?;
let data = start.checked_add(e.packed).and_then(|end| self.zip.get(start..end)).ok_or_else(damaged)?;
let bytes = match e.method {
0 if e.packed != e.size => return Err(damaged()),
0 => data.to_vec(),
8 => {
let mut out = Vec::with_capacity(e.size);
flate2::read::DeflateDecoder::new(data).take((max as u64).saturating_add(1)).read_to_end(&mut out).map_err(|_| damaged())?;
if out.len() > max {
return Err(ReadError::TooLarge(e.name.clone()));
}
out
}
m => return Err(ReadError::Method(e.name.clone(), m)),
};
let mut crc = Crc::new();
crc.update(&bytes);
if bytes.len() != e.size || crc.sum() != e.crc {
return Err(damaged());
}
Ok(Some(bytes))
}
}
/// The entries of a store-only archive written by [`store`] (read through its central directory,
/// checking every CRC) → `(name, bytes)`.
#[cfg(test)]
+18 -3
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Freeform Point",
[],
None,
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}",
"{at: [x,y] (document coordinates), color? (default: the gradient's colour there), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}",
has_doc,
add_point
),
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Freeform Point",
[],
None,
"{index? (default: the selected point), at?: [x,y] (document coordinates), color?, opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
"{index? (default: the selected point), at?: [x,y] (document coordinates), color?, keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} move or recolour a point of the freeform gradient → {index}",
has_doc,
set_point
),
@@ -134,6 +134,12 @@ fn point_fields(v: &Value, pt: &mut FreeformPoint, to_paint: Affine) -> std::res
Ok(())
}
/// The `color` of `p` as the document stores it: an RGB color in a CMYK document becomes CMYK
/// through the color settings ([`super::paint::new_color`]). `None` without a valid one.
fn given_color(s: &Session, p: &Value) -> Option<vectorcraft_color::Color> {
p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c))
}
/// Parse the `freeform` param of a gradient paint (`{points: [{at, color, opacity?, spread?}],
/// lines?: [[index, …]], mode?}`; points in the paint's space). Lossless for
/// `vectorcraft_tools::params::freeform_json`.
@@ -238,11 +244,15 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Bool(true)) => s.selected_freeform_point(),
Some(_) => return Err(bad(C, "`line` must be true or false")),
};
let given = given_color(s, p);
let index = edit(s, p, C, |f, sp| {
let q = sp.to_paint * at;
let (color, opacity) = f.sample(q, sp.scale);
let mut pt = FreeformPoint { opacity, ..FreeformPoint::new(q, color) };
point_fields(p, &mut pt, sp.to_paint)?;
if let Some(c) = given {
pt.color = c;
}
let i = f.add_point(pt);
if let Some(from) = join {
f.connect(from, i);
@@ -256,9 +266,14 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
fn set_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.freeform.setPoint";
let i = point_index(s, p, C)?;
let given = given_color(s, p);
edit(s, p, C, |f, sp| {
check_index(f, i)?;
point_fields(p, &mut f.points[i], sp.to_paint)
point_fields(p, &mut f.points[i], sp.to_paint)?;
if let Some(c) = given {
f.points[i].color = c;
}
Ok(())
})?;
Ok(json!({ "index": i }))
}
+48 -4
View File
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Gradient",
["Window", "Gradient"],
None,
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
has_doc,
edit_gradient
),
@@ -137,6 +137,47 @@ pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed<Value> {
Ok(v)
}
/// Stop colors to store, one per stop of `g` (params holding `stops`, before [`link_stops`]): the
/// color a stop gives and the color the document stores it as when it is new to the gradient, an
/// RGB color in a CMYK document becoming CMYK through the color settings
/// ([`super::paint::new_color`], with `p`'s `keepModel`). `None` for a stop that names a swatch,
/// which gives its color in the swatch's model, and for a color stored as given.
pub(crate) type StopColors = Vec<Option<(vectorcraft_color::Color, vectorcraft_color::Color)>>;
/// The [`StopColors`] of `g`'s stops.
pub(crate) fn stop_colors(s: &Session, p: &Value, g: &Value) -> StopColors {
let stops = g.get("stops").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
let stored = |st: &Value| {
let c = st.get("color").and_then(color_value).filter(|_| str_param(st, "swatch").is_none())?;
let to = super::paint::new_color(s, p, c);
(to != c).then_some((c, to))
};
stops.iter().map(stored).collect()
}
/// `g` (params holding `stops`, resolved by [`link_stops`] in their order) for an edit of paint
/// `old`, with each stop's color replaced by the one `stored` ([`stop_colors`]) gives for it,
/// unless `old`'s stops already have that color. A freeform gradient's points are colored from the
/// stored colors, and a stop list sent back with only its positions changed keeps its colors.
pub(crate) fn with_stored_stops<'a>(g: &'a Value, old: &Paint, stored: &StopColors) -> std::borrow::Cow<'a, Value> {
use std::borrow::Cow;
if stored.iter().all(Option::is_none) {
return Cow::Borrowed(g);
}
let had = |c: &vectorcraft_color::Color| matches!(old, Paint::Gradient(o) if o.gradient.stops.iter().any(|st| st.color == *c));
let mut v = g.clone();
let Some(stops) = v.get_mut("stops").and_then(Value::as_array_mut) else { return Cow::Borrowed(g) };
for (st, sc) in stops.iter_mut().zip(stored) {
let Some((from, to)) = sc.filter(|(from, _)| !had(from)) else { continue };
if st.get("color").and_then(color_value) == Some(from)
&& let Some(o) = st.as_object_mut()
{
o.insert("color".into(), color_json(&to));
}
}
Cow::Owned(v)
}
/// Parse `stops` params: at least two, each with a numeric `offset` (clamped to 0..1) and a valid
/// `color`; `opacity` is 0..1 (values above 1 are percentages) and `midpoint` is clamped to the
/// diamond's range; `swatch` and `tint` (%) keep a stop's link as given (resolve them first with
@@ -340,11 +381,13 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")),
Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?),
};
// New RGB stop colors take the document's model; those of the swatches the stops name don't.
let stored = stop_colors(s, p, p);
// Stops naming swatches take their colours from them.
let p = &link_stops(&s.doc()?.doc, p).map_err(|e| bad(C, e))?;
// Validate against the defaults first so bad params fail without touching the document.
let default_paint = if stroke { s.paint.stroke.clone() } else { s.paint.fill.clone() };
let new_default = apply_gradient_edit(&default_paint, p, None).map_err(|e| bad(C, e))?;
let new_default = apply_gradient_edit(&default_paint, &with_stored_stops(p, &default_paint, &stored), None).map_err(|e| bad(C, e))?;
let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform);
edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| {
if index.is_none()
@@ -353,7 +396,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
let lb = t.local_bounds();
for r in &mut t.runs {
let (cur, b) = run_paint_mut(r, stroke, lb);
*cur = apply_gradient_edit(cur, p, Some(b)).map_err(|e| bad(C, e))?;
*cur = apply_gradient_edit(cur, &with_stored_stops(p, cur, &stored), Some(b)).map_err(|e| bad(C, e))?;
}
return Ok(());
}
@@ -364,7 +407,8 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => f,
None => &|_| true,
};
let np = apply_gradient_edit_in(n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None), p, b, inside).map_err(|e| bad(C, e))?;
let old = n.appearance.paint_at(index, !stroke).unwrap_or(&Paint::None);
let np = apply_gradient_edit_in(old, &with_stored_stops(p, old, &stored), b, inside).map_err(|e| bad(C, e))?;
n.appearance.set_paint_at(index, !stroke, np);
if let Some(m) = mode
&& let Some(st) = n.appearance.stroke_at_mut(index)
+5 -3
View File
@@ -810,7 +810,8 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_from(p, def);
let idx = s.edit("New Artboard", |d, _| {
let id = d.next_artboard_id();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Artboard {}", d.artboards.len() + 1));
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty());
let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string);
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false });
Ok(d.artboards.len() - 1)
})?;
@@ -864,8 +865,9 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Artboard Options", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = now;
if let Some(n) = str_param(p, "name") {
a.name = n.to_string();
if let Some(n) = str_param(p, "name").map(str::trim) {
// A blank name gives the artboard its default name, Artboard N for the Nth artboard.
a.name = if n.is_empty() { format!("Artboard {}", i + 1) } else { n.to_string() };
}
let id = a.id;
if let Some(xf) = transform {
+4 -3
View File
@@ -391,11 +391,12 @@ fn remove_file(_: &str) -> Result<()> {
}
/// Library `name` as a file name without its extension: letters, digits, spaces, `-` and `_`
/// kept, other characters as `-` ("Library" when nothing is left).
/// kept, other characters as `-`, and a `-` after a Windows device name such as `CON` or `COM1`
/// (`fileio::safe_file_name`); "Library" when nothing is left.
pub fn file_stem_for(name: &str) -> String {
let safe: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
let safe = safe.trim();
if safe.is_empty() { "Library".to_string() } else { safe.to_string() }
if safe.is_empty() { "Library".to_string() } else { super::fileio::safe_file_name(safe) }
}
fn stem_of(path: &str) -> String {
@@ -884,7 +885,7 @@ fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
}
Kind::CharStyle | Kind::ParaStyle => {
let para = kind == Kind::ParaStyle;
let name = data.get("name").and_then(Value::as_str).unwrap_or("Style").to_string();
let name = data.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Style").to_string();
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
let doc = &s.doc()?.doc;
// The document's style of that name when it is the same; else a new one.
+53 -39
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Check Links",
[],
None,
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}",
"{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}",
has_doc,
check
),
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Link Info",
[],
None,
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
has_doc,
info
),
@@ -245,21 +245,24 @@ fn found_at(path: String) -> Option<Found> {
Some(Found { path, size, modified, bytes: None })
}
/// Where the file of `link` is: at its path, else at its relative path or by name in the
/// document's folder `dir`.
fn locate(link: &LinkInfo, dir: Option<&Path>) -> Option<Found> {
let mut paths = vec![link.path.clone()];
if let Some(dir) = dir {
let near = link.relative.iter().map(|r| dir.join(r)).chain([dir.join(link.name())]);
paths.extend(near.map(|p| p.to_string_lossy().into_owned()));
}
paths.into_iter().find_map(found_at)
/// Where the file of `link` (stored in the document saved at `doc_path`) is: at its path, else at
/// its relative path or by name in the document's folder. Only paths
/// [`fileio::document_path`] follows are looked at.
fn locate(link: &LinkInfo, doc_path: Option<&str>) -> Option<Found> {
let stored = [Some(link.path.as_str()), link.relative.as_deref(), Some(link.name())];
stored.into_iter().flatten().filter_map(|p| fileio::document_path(p, doc_path).ok()).find_map(found_at)
}
/// Where the file of group `g` is and whether it still has the content shown. Its bytes are read
/// only when its size or modification time changed.
fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
let Some(mut f) = locate(&g.link, dir) else { return (Status::Missing, None) };
/// Why the path of `link` (stored in the document saved at `doc_path`) isn't followed, when it
/// names another computer, a device or a URL.
fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option<craft_docpath::Refused> {
fileio::document_path(&link.path, doc_path).err().filter(|r| *r != craft_docpath::Refused::Invalid)
}
/// Where the file of group `g` (of the document saved at `doc_path`) is and whether it still has
/// the content shown. Its bytes are read only when its size or modification time changed.
fn probe(g: &Group, doc_path: Option<&str>) -> (Status, Option<Found>) {
let Some(mut f) = locate(&g.link, doc_path) else { return (Status::Missing, None) };
let l = &g.link;
if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified {
return (Status::Ok, Some(f));
@@ -274,11 +277,6 @@ fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
(if same { Status::Ok } else { Status::Modified }, Some(f))
}
/// The folder of the document saved at `doc_path`.
fn folder(doc_path: Option<&str>) -> Option<&Path> {
doc_path.and_then(|p| Path::new(p).parent())
}
/// Is image `key` of `d` showing its preview (its file not read)?
fn previewing(d: &Document, key: &str) -> bool {
d.images.get(key).is_none_or(ImageBlob::is_proxy)
@@ -314,7 +312,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
.filter(|g| seen.insert(g.link.path.clone()))
.map(|g| {
let own = |b: &&ImageBlob| !b.is_proxy() && fileio::format_for_name(g.link.name()).is_some_and(|f| f.mime == b.mime);
let found = locate(&g.link, folder(doc_path));
let found = locate(&g.link, doc_path);
let found_path = found.as_ref().map(|f| f.path.clone());
let bytes = found.and_then(|mut f| f.read().ok()).or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), found_path, bytes }
@@ -328,7 +326,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec<NodeId>)> {
groups(&st.doc, ids)
.into_iter()
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, folder(st.path.as_deref())).0 == Status::Missing))
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, st.path.as_deref()).0 == Status::Missing))
.map(|g| (g.link, g.ids))
.collect()
}
@@ -510,9 +508,23 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip))
}
/// Groups as `{name, path, ids}` rows (those with objects in the layers).
fn rows<'a>(gs: impl Iterator<Item = &'a Group>) -> Vec<Value> {
gs.filter(|g| !g.ids.is_empty()).map(|g| json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) })).collect()
/// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in
/// the layers), with `refused`: why the path isn't followed, when it isn't.
fn rows<'a>(gs: impl Iterator<Item = &'a Group>, doc_path: Option<&str>) -> Vec<Value> {
gs.filter(|g| !g.ids.is_empty())
.map(|g| {
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) });
note_refused(&mut row, &g.link, doc_path);
row
})
.collect()
}
/// `row["refused"]`: why the path of `link` isn't followed, when it isn't ([`refused`]).
fn note_refused(row: &mut Value, link: &LinkInfo, doc_path: Option<&str>) {
if let Some(r) = refused(link, doc_path) {
row["refused"] = json!(r.to_string());
}
}
fn ids_json(ids: &[NodeId]) -> Vec<u64> {
@@ -545,9 +557,9 @@ impl Resolved {
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved {
let mut out = Resolved::default();
for g in groups(d, None) {
let row = rows(std::iter::once(&g));
let row = rows(std::iter::once(&g), doc_path);
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
match probe(&g, folder(doc_path)) {
match probe(&g, doc_path) {
(Status::Missing, _) => out.missing.extend(row),
(Status::Modified, Some(mut f)) if update || unread => {
match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread.then_some(Preserve::Bounds))) {
@@ -714,10 +726,11 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
let (mut missing, mut modified) = (0, 0);
let mut links = vec![];
for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) {
let (status, found) = probe(g, folder(st.path.as_deref()));
let (status, found) = probe(g, st.path.as_deref());
missing += usize::from(status == Status::Missing);
modified += usize::from(status == Status::Modified);
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
note_refused(&mut row, &g.link, st.path.as_deref());
if let Some(f) = found.filter(|f| f.path != g.link.path) {
row["found"] = json!(f.path);
}
@@ -734,7 +747,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids");
let (mut reads, mut missing) = (vec![], vec![]);
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) {
match probe(&g, folder(st.path.as_deref())) {
match probe(&g, st.path.as_deref()) {
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
// Unchanged and showing the file: nothing to read unless asked for by id.
(Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {}
@@ -783,11 +796,9 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
None => {
let selected: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect();
match folder_param {
Some(_) if selected.is_empty() => groups(&st.doc, None)
.into_iter()
.filter(|g| probe(g, folder(st.path.as_deref())).0 == Status::Missing)
.flat_map(|g| g.ids)
.collect(),
Some(_) if selected.is_empty() => {
groups(&st.doc, None).into_iter().filter(|g| probe(g, st.path.as_deref()).0 == Status::Missing).flat_map(|g| g.ids).collect()
}
_ => selected,
}
}
@@ -969,7 +980,7 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, bool)> {
let mut out = BTreeMap::new();
for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) {
let (status, found) = probe(g, folder(st.path.as_deref()));
let (status, found) = probe(g, st.path.as_deref());
let found = found.map(|f| f.path).filter(|p| *p != g.link.path);
let preview = stale(&st.doc, g);
out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview))));
@@ -1110,7 +1121,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
done.extend(g.ids);
continue;
}
match probe(&g, folder(st.path.as_deref())) {
match probe(&g, st.path.as_deref()) {
(Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) {
Ok(read) => {
done.extend(&g.ids);
@@ -1190,10 +1201,13 @@ fn placement_json(o: PlacementOptions) -> Value {
/// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`.
fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, folder(st.path.as_deref())).0);
let stamp = file_stamp(&l.path);
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0);
let doc_path = st.path.as_deref();
let path = fileio::document_path(&l.path, doc_path).ok();
let stamp = path.as_deref().and_then(file_stamp);
let folder_len = l.path.len().saturating_sub(l.name().len());
out["status"] = json!(status.as_str());
note_refused(out, l, doc_path);
out["path"] = json!(l.path);
out["fileName"] = json!(l.name());
out["location"] = json!(l.path.get(..folder_len).unwrap_or_default().trim_end_matches(['/', '\\']));
@@ -1201,7 +1215,7 @@ fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value)
("page", l.page.map(u64::from)),
("fileSize", stamp.map(|s| s.0).or(l.size)),
("modified", stamp.and_then(|s| s.1).or(l.modified)),
("created", file_created(&l.path)),
("created", path.as_deref().and_then(file_created)),
] {
if let Some(v) = v {
out[k] = json!(v);
+61 -7
View File
@@ -14,7 +14,7 @@ use vectorcraft_doc::live::{
PreserveShape, Spine,
};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_geom::{Affine, PathData, Point, Vec2};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
use super::edit::selected_roots;
use super::expand::gradient_sampler;
@@ -96,6 +96,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
spine_move_anchor
),
cmd!(
"object.blend.spine.convertAnchor",
"Convert Anchor Point",
[],
None,
"{id? (default: the selected blend), anchor: index, to: smooth|corner, x?, y?} convert a blend spine point: dragging a corner creates symmetric smooth handles, clicking a point with handles retracts both. Without x/y, smooth uses the neighbouring spine points",
has_doc,
spine_convert_anchor
),
cmd!(
"object.blend.spine.reshapeSegment",
"Reshape Spine Segment",
@@ -247,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Mesh Point Color",
[],
None,
"{id, index, color, opacity?: 0..1} colour of one mesh point",
"{id, index, color, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour of one mesh point",
has_doc,
mesh_set_color
),
@@ -265,7 +274,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Mesh Line",
[],
None,
"{id, x, y, color?} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}",
"{id, x, y, color?, keepModel?: bool (as paint.setFill)} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}",
has_doc,
mesh_add_line
),
@@ -401,6 +410,7 @@ const DEFAULT_STEPS: u32 = 5;
const DEFAULT_DISTANCE: f64 = 10.0;
fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result<BlendSpacing> {
check_numbers(p, &["value", "steps", "distance"], cmd)?;
let num = |k: &str| p.get(k).and_then(Value::as_f64);
let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32));
let mode = str_param(p, "spacing");
@@ -653,6 +663,37 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.convertAnchor";
let index = index_param(p, "anchor", C)?;
let smooth = match str_param(p, "to") {
Some("smooth") => true,
Some("corner") => false,
_ => return Err(bad(C, "to must be smooth or corner")),
};
let target = match (p.get("x"), p.get("y")) {
(None, None) => None,
(Some(_), Some(_)) => Some(finite_point(p, C)?),
_ => return Err(bad(C, "x and y must be given together")),
};
edit_spine(s, p, C, "Convert Anchor Point", |sp, _, _| {
if smooth && target.is_none() {
if index >= sp.anchors.len() {
return Err(bad(C, "no such spine point"));
}
vectorcraft_tools::draw2::curvature_smooth(sp, index);
return Ok(());
}
let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?;
match target {
Some(q) if smooth && q.distance(a.p) > 1e-9 => *a = Anchor::smooth(a.p, q),
_ => a.retract(),
}
Ok(())
})?;
ok()
}
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.blend.spine.reshapeSegment";
let seg = index_param(p, "segment", C)?;
@@ -931,7 +972,11 @@ fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result<Value> {
// ---------- Envelope ----------
/// The number keys of a warp: `bend`, and the two distortions by their short and long names.
const WARP_NUMBERS: &[&str] = &["bend", "h", "v", "horizontalDistortion", "verticalDistortion"];
fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result<EnvelopeKind> {
check_numbers(p, WARP_NUMBERS, cmd)?;
let (mut style, mut bend, mut h, mut v, mut horizontal) = ("arc".to_string(), 50.0, 0.0, 0.0, true);
if let Some(EnvelopeKind::Warp { style: s0, bend: b0, h: h0, v: v0, horizontal: z0 }) = cur {
(style, bend, h, v, horizontal) = (s0.clone(), *b0, *h0, *v0, *z0);
@@ -965,6 +1010,7 @@ fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, f
/// `rows` and `cols` (or `columns`), whole numbers 1..50 each, defaulting to `default`.
fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> {
check_numbers(p, &["rows", "cols", "columns"], cmd)?;
let rows = f64_or(p, "rows", default.0 as f64);
let cols_key = if p.get("cols").and_then(Value::as_f64).is_some() { "cols" } else { "columns" };
let cols = f64_or(p, cols_key, default.1 as f64);
@@ -1201,6 +1247,8 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.envelope.options";
check_numbers(p, &["fidelity"], C)?;
check_numbers(p, WARP_NUMBERS, C)?;
let fid = p.get("fidelity").and_then(Value::as_f64);
if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) {
return Err(bad(C, "fidelity must be 0..100"));
@@ -1346,14 +1394,19 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?,
None => MeshAppearance::Flat,
};
check_numbers(p, &["highlight"], C)?;
let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0);
let at = point_param(p, "at");
let at = match p.get("at") {
None | Some(Value::Null) => None,
Some(v) => Some(finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?),
};
let (rows, cols) = if at.is_some() { (1, 1) } else { (rows, cols) };
let roots = roots_param(s, p)?;
let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| {
let mut out = vec![];
let mut index = None;
let mut refused = None;
let model = d.color_mode.model();
for id in &roots {
let Some(n) = d.node(*id).cloned() else { continue };
let m = if let NodeKind::Image(im) = &n.kind {
@@ -1373,7 +1426,7 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
match n.appearance.fill_paint() {
// The points take the colours the gradient paints there.
Paint::Gradient(g) => {
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b, model))).map(|mut m| {
m.highlight(app, highlight);
m
})
@@ -1481,8 +1534,9 @@ fn whole(x: f64, key: &str, cmd: &str) -> Result<f64> {
fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.setPointColor";
check_numbers(p, &["opacity"], C)?;
let index = index_param(p, "index", C)?;
let color = p.get("color").and_then(color_value);
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
let opacity = p.get("opacity").and_then(Value::as_f64);
if color.is_none() && opacity.is_none() {
return Err(bad(C, "missing `color` or `opacity`"));
@@ -1519,7 +1573,7 @@ fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.addLine";
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let color = p.get("color").and_then(color_value);
let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c));
let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| {
let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?;
if let Some(c) = color {
+4
View File
@@ -181,6 +181,10 @@ pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to redo".into())
}
/// The active document has a step to undo or to redo (Clear History).
pub fn can_clear_history(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.undo.is_empty() || !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "no history to clear".into())
}
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) }
+13 -5
View File
@@ -301,7 +301,7 @@ fn new_color_model(s: &Session, p: &Value) -> Option<vectorcraft_color::cms::Mod
/// `c` in `model` ([`new_color_model`]) when it is an RGB color, converted through the color
/// settings (the working spaces and their intent) as Document Color Mode converts it. Other colors
/// are returned as given.
fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
pub(crate) fn stored_color(model: Option<vectorcraft_color::cms::Model>, c: Color) -> Color {
match (c, model) {
(Color::Rgb { .. }, Some(m)) => {
let cms = vectorcraft_color::cms::active();
@@ -318,8 +318,8 @@ pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color {
}
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A
/// colour or gradient given in RGB takes the document's colour model ([`new_color_model`]),
/// converted through the color settings.
/// color or gradient (its stops and freeform points) given in RGB takes the document's color
/// model ([`new_color_model`]), converted through the color settings.
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
let model = new_color_model(s, p);
let new_color = |c: Color| stored_color(model, c);
@@ -342,9 +342,17 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
};
}
if let Some(g) = p.get("gradient") {
// A stop naming a process swatch keeps the swatch's model.
let stored = super::gradient::stop_colors(s, p, g);
let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?;
let mut gp = super::gradient::parse_gradient(&g).map_err(|e| bad("paint", e))?;
gp.gradient.stops.iter_mut().filter(|st| st.swatch.is_none()).for_each(|st| st.color = new_color(st.color));
let mut gp = super::gradient::parse_gradient(&super::gradient::with_stored_stops(&g, &Paint::None, &stored)).map_err(|e| bad("paint", e))?;
if g.get("stops").is_none() {
// The default stops, white to black.
gp.gradient.stops.iter_mut().for_each(|st| st.color = new_color(st.color));
}
if let Some(f) = &mut gp.freeform {
f.points.iter_mut().for_each(|pt| pt.color = new_color(pt.color));
}
return Ok(Some(Paint::Gradient(Box::new(gp))));
}
if let Some(c) = p.get("color") {
+82 -2
View File
@@ -2,7 +2,7 @@
//! Character/Paragraph attributes.
use serde_json::{Value, json};
use vectorcraft_doc::{Composer, NodeKind, Scaling};
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify};
use vectorcraft_geom::{Rect, Vec2};
use super::*;
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -236,6 +236,34 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector
/// Largest indent or paragraph spacing (points; Illustrator's limit).
const MAX_PARA: f64 = 1296.0;
/// A justification spacing of `text.setFormat` (`wordSpacing`, `letterSpacing`, `glyphScaling`):
/// `[min, desired, max]` or `{min?, desired?, max?}`, in percent. The values not given are kept.
fn spacing_param(p: &Value, key: &str, c: &str) -> Result<Option<[Option<f64>; 3]>> {
let number = |v: &Value| v.as_f64().filter(|v| v.is_finite());
let err = || bad(c, format!("`{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent"));
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Array(a)) if a.len() == 3 => {
let mut out = [None; 3];
for (o, v) in out.iter_mut().zip(a) {
*o = Some(number(v).ok_or_else(err)?);
}
Ok(Some(out))
}
Some(Value::Object(o)) => {
let mut out = [None; 3];
for (k, v) in o {
let i = ["min", "desired", "max"].iter().position(|n| n == k).ok_or_else(err)?;
if let Some(slot) = out.get_mut(i) {
*slot = Some(number(v).ok_or_else(err)?);
}
}
Ok(Some(out))
}
Some(_) => Err(err()),
}
}
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setFormat";
let ids = super::typecmd::text_targets(s, p, C)?;
@@ -269,6 +297,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"hyphenLimit",
"hyphenationZone",
"hyphenateCapitalized",
"wordSpacing",
"letterSpacing",
"glyphScaling",
"autoLeading",
"singleWordJustification",
"mojikumi",
"kinsoku",
"direction",
@@ -278,6 +311,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"digits",
"fontAxes",
"burasagari",
"romanHangingPunctuation",
"composer",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
@@ -338,6 +372,24 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
}),
};
let spacing = |k: &str| spacing_param(p, k, C);
let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?);
let single_word = match p.get("singleWordJustification") {
None => None,
Some(v) => Some(match v.as_str() {
Some("fullJustify") => SingleWordJustify::FullJustify,
Some("alignLeft") => SingleWordJustify::AlignLeft,
Some("alignCenter") => SingleWordJustify::AlignCenter,
Some("alignRight") => SingleWordJustify::AlignRight,
_ => return Err(bad(C, "`singleWordJustification` must be \"fullJustify\", \"alignLeft\", \"alignCenter\" or \"alignRight\"")),
}),
};
let auto_leading = match p.get("autoLeading") {
None => None,
Some(v) => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, "`autoLeading` must be a number"))?),
};
// A paragraph whose Minimum, Desired and Maximum would no longer be in order.
let mut out_of_order: Option<&str> = None;
let range = super::typecmd::TextRange::parse(p, C)?;
// The character attributes, given the baseline shift and horizontal scale in the type's own units.
let chars = |st: &mut vectorcraft_doc::CharStyle, baseline_shift: Option<f64>, h_scale: Option<f64>| {
@@ -429,6 +481,28 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = flag("hyphenateCapitalized") {
para.hyphenation.capitalized = v;
}
let j = &mut para.justification;
for (name, given, value, limits) in [
("wordSpacing", &word_spacing, &mut j.word_spacing, JustificationOptions::WORD_SPACING),
("letterSpacing", &letter_spacing, &mut j.letter_spacing, JustificationOptions::LETTER_SPACING),
("glyphScaling", &glyph_scaling, &mut j.glyph_scaling, JustificationOptions::GLYPH_SCALING),
] {
let Some(given) = given else { continue };
for (v, g) in value.iter_mut().zip(given) {
if let Some(g) = g {
*v = g.clamp(limits.0, limits.1);
}
}
if !(value[0] <= value[1] && value[1] <= value[2]) {
out_of_order.get_or_insert(name);
}
}
if let Some(v) = auto_leading {
j.auto_leading = v.clamp(JustificationOptions::AUTO_LEADING.0, JustificationOptions::AUTO_LEADING.1);
}
if let Some(v) = single_word {
j.single_word = v;
}
if let Some(v) = mojikumi {
para.mojikumi = v;
}
@@ -444,10 +518,16 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = burasagari {
para.burasagari = v;
}
if let Some(v) = flag("romanHangingPunctuation") {
para.roman_hanging = v;
}
if let Some(v) = composer {
para.composer = v;
}
});
if let Some(name) = out_of_order {
return Err(bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")));
}
super::typecmd::refresh_bounds(t);
}
// Graph labels keep a change made to all of their text through the graph being drawn again.
+23 -6
View File
@@ -14,8 +14,9 @@
//! `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, except for an effect's
//! `color` that the effect can't read, which `effect.apply` and `effect.setParams` refuse
//! themselves ([`check_effect_color`]).
//! `color` that the effect can't read and a Levels or Curves `channel` that the effect doesn't
//! have, which `effect.apply` and `effect.setParams` refuse themselves ([`check_effect_color`],
//! [`check_effect_channel`]).
use serde_json::Value;
@@ -214,9 +215,10 @@ 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, 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.
/// default's type, a `color` the effect can draw ([`check_effect_color`]) and a Levels or Curves
/// `channel` the effect has ([`check_effect_channel`]). 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) {
@@ -243,7 +245,8 @@ pub fn check_effect_params(id: &str, effect: &str, params: &Value) -> Result<()>
_ => {}
}
}
check_effect_color(id, effect, params)
check_effect_color(id, effect, params)?;
check_effect_channel(id, effect, params)
}
/// Can an effect read `v` as its `color`: a string [`Color::from_hex`] reads (`#rrggbb`, `#rgb`,
@@ -280,6 +283,20 @@ pub(crate) fn check_effect_color(id: &str, effect: &str, params: &Value) -> Resu
Ok(())
}
/// The `channel` in `params` of Levels or Curves (`effect`), as command `id` was given it: a
/// parameter error naming the channels unless the effect reads it
/// ([`vectorcraft_render::effects::is_channel`]); the effect draws any other value as RGB.
/// `effect.apply` and `effect.setParams` run this themselves before they edit, as they run
/// [`check_effect_color`].
pub(crate) fn check_effect_channel(id: &str, effect: &str, params: &Value) -> Result<()> {
let key = "channel";
let Some(v) = params.get(key) else { return Ok(()) };
if !matches!(effect, "adjust.levels" | "adjust.curves") || v.as_str().is_some_and(vectorcraft_render::effects::is_channel) {
return Ok(());
}
Err(bad(id, format!("`{key}` must be one of {}", vectorcraft_render::effects::CHANNELS.join(", "))))
}
/// Commands whose params aren't checked yet (#1442): they read keys in ways the audit of the
/// descriptions couldn't follow (a list of keys in a loop, a whole object read as settings), so
/// their descriptions aren't proven to name every key they take. Sorted; take a command out once
+32 -16
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Pattern"],
None,
"{ids?, name?, tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
"{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
has_selection,
pattern_make
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save a Copy",
[],
None,
"{name?} while editing, save the current tile as a new pattern swatch → {name}",
"{name? (trimmed; blank: <pattern> copy)} while editing, save the current tile as a new pattern swatch → {name}",
in_pattern_edit,
pattern_save_copy
),
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pattern Options",
["Window", "Pattern Options"],
None,
"{name? (default: the pattern being edited), newName?, tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
"{name? (default: the pattern being edited), newName? (trimmed; blank: the name is kept), tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool, showSwatchBounds?: bool (outline the part of the tiling the swatch repeats, dashed)}",
has_doc,
pattern_options
),
@@ -210,14 +210,14 @@ fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
}
fn unique_pattern_name(d: &Document, base: &str) -> String {
if d.pattern(base).is_none() && d.swatch(base).is_none() {
if d.pattern(base).is_none() && !d.swatch_name_taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap_or_else(|| base.to_string())
(2..).map(|i| format!("{base} {i}")).find(|n| d.pattern(n).is_none() && !d.swatch_name_taken(n)).unwrap_or_else(|| base.to_string())
}
fn next_pattern_name(d: &Document) -> String {
(1..).map(|i| format!("New Pattern {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap_or_else(|| "New Pattern".to_string())
(1..).map(|i| format!("New Pattern {i}")).find(|n| d.pattern(n).is_none() && !d.swatch_name_taken(n)).unwrap_or_else(|| "New Pattern".to_string())
}
/// Add pattern `def` and its swatch.
@@ -294,7 +294,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "width/height must be > 0"));
}
let edit = bool_or(p, "edit", true);
let wanted = str_param(p, "name").map(str::to_string);
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let name = s.edit("Make Pattern", |d, sel| {
let name = match &wanted {
Some(n) => unique_pattern_name(d, n),
@@ -388,12 +388,12 @@ fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
}
fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
let wanted = str_param(p, "name").map(str::to_string);
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let name = s.edit("Save a Copy", |d, _| {
let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?;
let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?;
let art: Vec<Arc<Node>> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default();
let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern)));
let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern.trim())));
def.name = name.clone();
def.art = copies(d, &art).into_iter().map(Arc::new).collect();
if def.size_tile_to_art {
@@ -419,11 +419,15 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, format!("{k} must be > 0")));
}
}
let new_name = str_param(p, "newName").map(str::to_string).filter(|n| !n.is_empty() && *n != name);
if let Some(n) = &new_name
&& s.doc()?.doc.pattern(n).is_some()
{
return Err(bad(C, format!("a pattern named `{n}` exists")));
let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
if let Some(n) = &new_name {
let d = &s.doc()?.doc;
if d.pattern(n).is_some() {
return Err(bad(C, format!("a pattern named `{n}` exists")));
}
if swatch_name_clash(d, &name, n) {
return Err(bad(C, format!("a swatch or color group named `{n}` exists")));
}
}
let overlap = match p.get("overlap") {
Some(o) => Overlap {
@@ -479,7 +483,19 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": out_name }))
}
/// Rename a pattern in swatches, paints and the edit record.
/// Is `new` taken for pattern `old` by a swatch or a color group (they share one namespace,
/// [`Document::swatch_name_taken`])? It is free when the swatches named `new` are one or more
/// swatches of the pattern and none of the pattern's swatches is named `old` (those take the new
/// name in [`rename_pattern_uses`]).
fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool {
let own = |w: &vectorcraft_color::Swatch| pattern_name(&w.paint).is_some_and(|p| p == old);
let mut holders = d.swatches_iter().filter(|w| w.name == new).peekable();
let only_own = holders.peek().is_some() && holders.all(own);
d.swatch_name_taken(new) && !(only_own && !d.swatches_iter().any(|w| w.name == old && own(w)))
}
/// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the
/// pattern's own take the new name.
fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) {
let fix = |p: &mut Paint| {
if let Paint::Pattern { pattern, .. } = p
@@ -489,7 +505,7 @@ fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) {
}
};
for sw in d.swatches.iter_mut().chain(d.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) {
if sw.name == old {
if sw.name == old && pattern_name(&sw.paint).is_some_and(|p| p == old) {
sw.name = new.to_string();
}
fix(&mut sw.paint);
+3 -2
View File
@@ -68,8 +68,9 @@ pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
let board = board.ok_or_else(|| bad(cmd, format!("page {page}: `{file}` has {n} artboard(s)")))?;
let layers = std::mem::take(&mut l.doc.layers);
let nodes = super::on_artboard(&layers, board);
let frame = if bounding { nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())) } else { Some(board) };
let symbols = l.doc.symbol_boxes();
let nodes = super::on_artboard(&symbols, &layers, board);
let frame = if bounding { super::shown_bounds(&symbols, &nodes) } else { Some(board) };
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{file}` has no art to place")))?;
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
}
+977
View File
@@ -0,0 +1,977 @@
//! Placing a Word document (`.docx`): its text set as area type with the character and paragraph
//! formatting VectorCraft has, read as ECMA-376 Part 1 describes WordprocessingML
//! (<https://ecma-international.org/publications-and-standards/standards/ecma-376/>), in its
//! Transitional and Strict namespaces.
//!
//! - The package is a ZIP archive ([`fileio::zip`]). `_rels/.rels` names the main document part
//! (else `word/document.xml`), and that part's relationships name its styles and theme parts
//! (else `styles.xml` and `theme/theme1.xml` next to it).
//! - Text: each paragraph (`w:p`) is a paragraph, and so is each table row, whose cells are
//! separated by tabs. A text-wrapping break (`w:br`) and a carriage return (`w:cr`) start a new
//! paragraph; page and column breaks are skipped. `w:tab` and `w:ptab` are tabs, and
//! `w:noBreakHyphen` and `w:softHyphen` are their hyphens. Text in insertions, moves, hyperlinks,
//! smart tags, content controls, custom XML and simple fields is read, and of a complex field,
//! its result.
//! - Character formatting: the font (`w:rFonts`: `w:asciiTheme`, `w:ascii`, `w:hAnsiTheme`, then
//! `w:hAnsi`; a theme font is the theme's major or minor Latin font), the size, bold, italic,
//! underline (every style but `none`), strikethrough and double strikethrough, all caps, small
//! caps, superscript and subscript (`w:vertAlign`), raised and lowered text (`w:position`) and
//! the color.
//! - Paragraph formatting: alignment (`w:jc`), start and end indents (`w:start` or `w:left`,
//! `w:end` or `w:right`: the right and left indents of a right-to-left paragraph), first-line and
//! hanging indents, space before and after, and direction (`w:bidi`).
//! - Styles (§17.7.2): the document defaults, then the paragraph style (else the default paragraph
//! style) and the styles it is based on, then the run's character style (else the default
//! character style) and the styles it is based on, then direct formatting. Bold, italic, caps,
//! small caps, strikethrough and hidden text are toggle properties (§17.7.3): a style that turns
//! one on reverses the state the levels before it give, and direct formatting sets it.
//!
//! Hidden text (`w:vanish`), deleted text, field instructions, drawings, pictures, objects, symbol
//! characters (`w:sym`), note and comment references, headers, footers, notes, comments and list
//! numbers are left out. A package with more than [`MAX_FILES`] files, a part that unpacks to more
//! than [`MAX_PART`] bytes or nests elements more than [`MAX_NESTING`] deep, and a document with
//! more than [`MAX_PARAGRAPHS`] paragraphs, [`MAX_RUNS`] runs of one formatting or
//! [`text::MAX_TEXT`] bytes of text are refused. Tables, content controls and other containers
//! nested more than [`MAX_DEPTH`] deep are skipped, and so are fields nested deeper.
use std::collections::HashMap;
use roxmltree::Node;
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
use super::text::{self, Story};
use super::*;
use crate::cmd::fileio::zip::{self, Archive, ReadError};
/// Word documents as `file.place` reports them (not a format `document.open` reads).
pub(super) const FORMAT: fileio::Format = fileio::Format {
id: "docx",
label: "Word",
extensions: fileio::DOCX_EXTS,
mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
read: true,
write: false,
raster: false,
options: &[],
};
/// The most files a Word document's package may hold.
const MAX_FILES: usize = 10_000;
/// The most bytes a part read from the package may unpack to.
const MAX_PART: usize = 16 << 20;
/// The most XML nodes a part read from the package may have.
const MAX_NODES: u32 = 2_000_000;
/// The deepest nesting of XML elements a part read from the package may have.
const MAX_NESTING: usize = 256;
/// The deepest nesting of tables, content controls and other containers read; deeper ones are
/// skipped, and so are fields nested deeper.
const MAX_DEPTH: usize = 64;
/// The most styles read from the styles part.
const MAX_STYLES: usize = 4096;
/// The most styles one style's `w:basedOn` chain takes in.
const MAX_BASED_ON: usize = 32;
/// The most fonts the document's formatting names.
const MAX_FONTS: usize = 4096;
/// The most bytes of a font name kept.
const MAX_NAME: usize = 256;
/// The most paragraphs placed type takes from one Word document.
const MAX_PARAGRAPHS: usize = 100_000;
/// The most runs of one character formatting placed type takes from one Word document.
const MAX_RUNS: usize = 100_000;
/// WordprocessingML's namespaces: Transitional, then Strict.
const W: [&str; 2] = ["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"];
/// Is the file `name` with `bytes` a Word document: a ZIP archive named `.docx`, or one that holds
/// `word/document.xml`?
pub(super) fn is_docx(name: &str, bytes: &[u8]) -> bool {
zip::is_zip(bytes)
&& (fileio::DOCX_EXTS.contains(&fileio::extension(name).as_str())
|| Archive::open(bytes, MAX_FILES).is_ok_and(|z| z.contains("word/document.xml")))
}
/// The text of a Word document's `bytes` (a ZIP package) with its character and paragraph
/// formatting.
pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> {
let zip = Archive::open(bytes, MAX_FILES).map_err(|e| package_error(cmd, e))?;
let main = relationship(&zip, "", "officeDocument", cmd)?.unwrap_or_else(|| "word/document.xml".into());
let Some(xml) = part(&zip, &main, cmd)? else { return Err(bad(cmd, "not a Word document: its package has no main document part")) };
let doc = parse(&xml, &main, cmd)?;
if local(doc.root_element()) != Some("document") {
return Err(bad(cmd, "not a Word document: its main part is not a WordprocessingML document"));
}
let folder = main.rfind('/').and_then(|i| main.get(..=i)).unwrap_or_default();
let mut r = Reader::default();
let theme = relationship(&zip, &main, "theme", cmd)?.unwrap_or_else(|| format!("{folder}theme/theme1.xml"));
if let Some(xml) = part(&zip, &theme, cmd)? {
r.theme_fonts(&parse(&xml, &theme, cmd)?);
}
let styles = relationship(&zip, &main, "styles", cmd)?.unwrap_or_else(|| format!("{folder}styles.xml"));
if let Some(xml) = part(&zip, &styles, cmd)? {
r.styles(&parse(&xml, &styles, cmd)?);
}
if let Some(body) = doc.root_element().children().find(|n| local(*n) == Some("body")) {
r.block(body, 0);
}
r.finish(cmd)
}
/// The error for a package (or a part of it) the ZIP reader can't read.
fn package_error(cmd: &str, e: ReadError) -> EngineError {
bad(
cmd,
match e {
ReadError::NotZip => "not a Word document: it isn't a ZIP package".to_string(),
ReadError::TooManyFiles => format!("the Word document's package has more than {MAX_FILES} files"),
ReadError::Zip64 => "the Word document's package uses ZIP64 records, which VectorCraft doesn't read".to_string(),
ReadError::Encrypted(name) => format!("`{name}` in the Word document is encrypted"),
ReadError::Method(name, m) => format!("`{name}` in the Word document uses compression method {m}, which VectorCraft doesn't read"),
ReadError::TooLarge(name) => format!("`{name}` in the Word document is too large to place (at most {} MB unpacked)", MAX_PART >> 20),
ReadError::Damaged(name) if name.is_empty() => "the Word document's package is damaged".to_string(),
ReadError::Damaged(name) => format!("the Word document's package is damaged: `{name}` can't be read"),
},
)
}
/// The part `name` of the package as text (UTF-8, or UTF-16 with a byte-order mark); None: the
/// package has no such part.
fn part(zip: &Archive, name: &str, cmd: &str) -> Result<Option<String>> {
let Some(bytes) = zip.read(name, MAX_PART).map_err(|e| package_error(cmd, e))? else { return Ok(None) };
let utf16 = |rest: &[u8], le: bool| -> String {
let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
};
let text = match &bytes[..] {
[0xEF, 0xBB, 0xBF, rest @ ..] => std::str::from_utf8(rest).map(str::to_string),
[0xFF, 0xFE, rest @ ..] => Ok(utf16(rest, true)),
[0xFE, 0xFF, rest @ ..] => Ok(utf16(rest, false)),
_ => std::str::from_utf8(&bytes).map(str::to_string),
};
text.map(Some).map_err(|_| bad(cmd, format!("`{name}` in the Word document is not UTF-8 or UTF-16 text")))
}
/// The XML of the part `name`.
fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result<roxmltree::Document<'a>> {
// roxmltree parses nested elements recursively.
if nesting(xml) > MAX_NESTING {
return Err(bad(cmd, format!("`{name}` in the Word document nests elements more than {MAX_NESTING} deep")));
}
let opts = roxmltree::ParsingOptions { nodes_limit: MAX_NODES, ..roxmltree::ParsingOptions::default() };
roxmltree::Document::parse_with_options(xml, opts).map_err(|e| bad(cmd, format!("`{name}` in the Word document is not well-formed XML: {e}")))
}
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
/// CDATA sections, processing instructions, declarations and quoted attribute values.
fn nesting(xml: &str) -> usize {
let b = xml.as_bytes();
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
// The index just past the first `end` at or after `from` (the end of the text if none).
let past = |from: usize, end: &[u8]| {
b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len())
};
while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) {
let start = i + at;
let rest = b.get(start..).unwrap_or_default();
if rest.starts_with(b"<!--") {
i = past(start + 4, b"-->");
} else if rest.starts_with(b"<![CDATA[") {
i = past(start + 9, b"]]>");
} else if rest.starts_with(b"<?") {
i = past(start + 2, b"?>");
} else if rest.starts_with(b"<!") || rest.starts_with(b"</") {
depth = depth.saturating_sub(usize::from(rest.starts_with(b"</")));
i = past(start + 2, b">");
} else {
// A start tag: up to its `>` outside quotes; `/>` closes it at once.
let mut j = start + 1;
let mut quote = None;
while let Some(&c) = b.get(j) {
match (quote, c) {
(None, b'"' | b'\'') => quote = Some(c),
(Some(q), _) if c == q => quote = None,
(None, b'>') => break,
_ => {}
}
j += 1;
}
if b.get(j.saturating_sub(1)) != Some(&b'/') {
depth += 1;
most = most.max(depth);
}
i = j + 1;
}
}
most
}
/// The part that the first relationship of part `source` (`""`: the package) whose type ends in
/// `/{kind}` points to; None when the package has no relationships part for `source` or it names
/// no such part.
fn relationship(zip: &Archive, source: &str, kind: &str, cmd: &str) -> Result<Option<String>> {
let (folder, file) = source.rfind('/').map_or(("", source), |i| (source.get(..=i).unwrap_or_default(), source.get(i + 1..).unwrap_or_default()));
let rels = format!("{folder}_rels/{file}.rels");
let Some(xml) = part(zip, &rels, cmd)? else { return Ok(None) };
let doc = parse(&xml, &rels, cmd)?;
let target = doc
.root_element()
.children()
.filter(|n| n.tag_name().name() == "Relationship" && n.attribute("TargetMode") != Some("External"))
.find(|n| n.attribute("Type").is_some_and(|t| t.rsplit('/').next() == Some(kind)))
.and_then(|n| n.attribute("Target"));
Ok(target.map(|t| resolve(folder, t)))
}
/// The part name `target` gives, relative to the folder `folder` (`""` or ending in `/`).
fn resolve(folder: &str, target: &str) -> String {
let joined = match target.strip_prefix('/') {
Some(absolute) => absolute.to_string(),
None => format!("{folder}{target}"),
};
let mut parts: Vec<&str> = vec![];
for segment in joined.split('/') {
match segment {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
/// `n`'s local name when it is a WordprocessingML element.
fn local<'a>(n: Node<'a, '_>) -> Option<&'a str> {
(n.is_element() && n.tag_name().namespace().is_some_and(|ns| W.contains(&ns))).then(|| n.tag_name().name())
}
/// `n`'s first WordprocessingML child element named `name`.
fn child<'a, 'i>(n: Node<'a, 'i>, name: &str) -> Option<Node<'a, 'i>> {
n.children().find(|c| local(*c) == Some(name))
}
/// `n`'s WordprocessingML attribute `name`.
fn attr<'a>(n: Node<'a, '_>, name: &str) -> Option<&'a str> {
n.attributes().find(|a| a.name() == name && a.namespace().is_some_and(|ns| W.contains(&ns))).map(|a| a.value())
}
/// An on/off property (ST_OnOff): on unless `w:val` is `false`, `0` or `off`.
fn on(n: Node) -> bool {
!matches!(attr(n, "val"), Some("false" | "0" | "off"))
}
/// A measure `v` in units of `1 / per_pt` point: a number in those units, or a number with a unit
/// (`mm`, `cm`, `in`, `pt`, `pc`, `pi`).
fn measure(v: &str, per_pt: f64) -> Option<f64> {
const UNITS: [(&str, f64); 6] = [("mm", 72.0 / 25.4), ("cm", 72.0 / 2.54), ("in", 72.0), ("pt", 1.0), ("pc", 12.0), ("pi", 12.0)];
let v = v.trim();
let n = match UNITS.iter().find_map(|(unit, pt)| v.strip_suffix(unit).map(|n| (n, pt))) {
Some((n, pt)) => n.trim().parse::<f64>().ok()? * pt * per_pt,
None => v.parse::<f64>().ok()?,
};
n.is_finite().then_some(n)
}
/// Twentieths of a point.
fn twips(n: Node, name: &str) -> Option<f64> {
attr(n, name).and_then(|v| measure(v, 20.0))
}
/// Half points.
fn half_points(n: Node) -> Option<f64> {
attr(n, "val").and_then(|v| measure(v, 2.0))
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Script {
#[default]
Normal,
Super,
Sub,
}
/// The run properties one level of the style hierarchy sets (None: it doesn't set that one).
#[derive(Clone, Copy, Debug, Default)]
struct RunProps {
/// The font's number in [`Reader::fonts`].
font: Option<usize>,
half_points: Option<f64>,
bold: Option<bool>,
italic: Option<bool>,
caps: Option<bool>,
small_caps: Option<bool>,
strike: Option<bool>,
dstrike: Option<bool>,
hidden: Option<bool>,
underline: Option<bool>,
script: Option<Script>,
/// Raised (positive) or lowered text in half points.
shift: Option<f64>,
/// Some(None): automatic (the default color).
color: Option<Option<[u8; 3]>>,
}
/// Character formatting.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Chars {
font: Option<usize>,
/// None: VectorCraft's default size.
half_points: Option<f64>,
bold: bool,
italic: bool,
caps: bool,
small_caps: bool,
strike: bool,
dstrike: bool,
hidden: bool,
underline: bool,
script: Script,
shift: f64,
color: Option<[u8; 3]>,
}
impl Chars {
/// `p` applied over this formatting: as a style's (a toggle property turned on reverses its
/// state, turned off leaves it) or as direct formatting (it sets each property).
fn apply(&mut self, p: &RunProps, style: bool) {
let toggle = |state: &mut bool, v: Option<bool>| match v {
Some(true) if style => *state = !*state,
Some(v) if !style => *state = v,
_ => {}
};
toggle(&mut self.bold, p.bold);
toggle(&mut self.italic, p.italic);
toggle(&mut self.caps, p.caps);
toggle(&mut self.small_caps, p.small_caps);
toggle(&mut self.strike, p.strike);
toggle(&mut self.hidden, p.hidden);
self.font = p.font.or(self.font);
self.half_points = p.half_points.or(self.half_points);
self.dstrike = p.dstrike.unwrap_or(self.dstrike);
self.underline = p.underline.unwrap_or(self.underline);
self.script = p.script.unwrap_or(self.script);
self.shift = p.shift.unwrap_or(self.shift);
self.color = p.color.unwrap_or(self.color);
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Align {
#[default]
Left,
Center,
Right,
/// Justified, the last line at the start (the right of a right-to-left paragraph).
Both,
/// Justified, the last line too.
Distribute,
Start,
End,
}
/// The paragraph properties one level of the style hierarchy sets; lengths in twips.
#[derive(Clone, Copy, Debug, Default)]
struct ParaProps {
align: Option<Align>,
/// The indent at the paragraph's start (the left of a left-to-right paragraph).
start: Option<f64>,
/// The indent at the paragraph's end.
end: Option<f64>,
first: Option<f64>,
before: Option<f64>,
after: Option<f64>,
bidi: Option<bool>,
}
/// Paragraph formatting; lengths in twips.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Para {
align: Align,
start: f64,
end: f64,
first: f64,
before: f64,
after: f64,
rtl: Option<bool>,
}
impl Para {
fn apply(&mut self, p: &ParaProps) {
self.align = p.align.unwrap_or(self.align);
self.start = p.start.unwrap_or(self.start);
self.end = p.end.unwrap_or(self.end);
self.first = p.first.unwrap_or(self.first);
self.before = p.before.unwrap_or(self.before);
self.after = p.after.unwrap_or(self.after);
self.rtl = p.bidi.or(self.rtl);
}
}
/// A style of the styles part.
struct Style {
based_on: Option<String>,
run: RunProps,
para: ParaProps,
}
#[derive(Default)]
struct Reader {
/// The theme's major and minor Latin fonts.
theme: [Option<String>; 2],
fonts: Vec<String>,
font_numbers: HashMap<String, usize>,
styles: HashMap<String, Style>,
default_run: RunProps,
default_para: ParaProps,
/// The default paragraph style's id.
default_style: Option<String>,
/// The default character style's id.
default_char_style: Option<String>,
/// The paragraph being read: its formatting, and its run formatting before character styles
/// and direct formatting.
para: Para,
base: Chars,
/// A paragraph that has ended (its formatting and its mark's): its break goes before the
/// next text.
pending: Option<(Para, Chars)>,
/// The last cell's paragraph, which its row's break ends.
cell_end: Option<(Para, Chars)>,
/// A cell just ended: a tab goes before the next character of its row.
cell_tab: bool,
/// The complex fields open: true while their instructions are being read.
fields: Vec<bool>,
/// Fields begun past [`MAX_DEPTH`] and not yet ended.
field_overflow: usize,
/// The text, in runs of one character formatting; paragraphs end with `\n`.
spans: Vec<(String, Chars)>,
/// The bytes of text in `spans`.
len: usize,
/// The formatting of each paragraph that has ended.
paras: Vec<Para>,
/// Past [`MAX_PARAGRAPHS`], [`MAX_RUNS`] or [`text::MAX_TEXT`].
full: bool,
}
impl Reader {
/// The major and minor fonts of a theme part's font scheme.
fn theme_fonts(&mut self, doc: &roxmltree::Document) {
for (slot, scheme) in self.theme.iter_mut().zip(["majorFont", "minorFont"]) {
*slot = doc
.descendants()
.find(|n| n.tag_name().name() == scheme)
.and_then(|n| n.children().find(|c| c.tag_name().name() == "latin"))
.and_then(|n| n.attribute("typeface"))
.map(font_name)
.filter(|f| !f.is_empty());
}
}
/// The document defaults and the styles of the styles part.
fn styles(&mut self, doc: &roxmltree::Document) {
for n in doc.root_element().children() {
match local(n) {
Some("docDefaults") => {
let props = |outer: &str, inner: &str| child(n, outer).and_then(|d| child(d, inner));
if let Some(rpr) = props("rPrDefault", "rPr") {
self.default_run = self.run_props(rpr);
}
if let Some(ppr) = props("pPrDefault", "pPr") {
self.default_para = para_props(ppr);
}
}
Some("style") if self.styles.len() < MAX_STYLES => {
let Some(id) = attr(n, "styleId") else { continue };
let default = match attr(n, "type") {
Some("paragraph") => Some(&mut self.default_style),
Some("character") => Some(&mut self.default_char_style),
_ => None,
};
if let Some(default) = default.filter(|_| attr(n, "default").is_some_and(|v| matches!(v, "1" | "true" | "on"))) {
default.get_or_insert_with(|| id.to_string());
}
let run = child(n, "rPr").map(|r| self.run_props(r)).unwrap_or_default();
let para = child(n, "pPr").map(para_props).unwrap_or_default();
let based_on = child(n, "basedOn").and_then(|b| attr(b, "val")).map(str::to_string);
self.styles.entry(id.to_string()).or_insert(Style { based_on, run, para });
}
_ => {}
}
}
}
/// Style `id` and the styles it is based on, the first of the chain first.
fn chain(&self, id: Option<&str>) -> Vec<&Style> {
let mut ids: Vec<&str> = vec![];
let mut chain = vec![];
let mut next = id;
while let Some(id) = next {
if ids.contains(&id) || ids.len() >= MAX_BASED_ON {
break;
}
let Some(style) = self.styles.get(id) else { break };
ids.push(id);
chain.push(style);
next = style.based_on.as_deref();
}
chain.reverse();
chain
}
/// The number of the font a `w:rFonts` element names.
fn font(&mut self, n: Node) -> Option<usize> {
let theme = |v: &str| self.theme.get(usize::from(v.starts_with("minor"))).and_then(Option::as_deref);
let name = attr(n, "asciiTheme")
.and_then(theme)
.or_else(|| attr(n, "ascii"))
.or_else(|| attr(n, "hAnsiTheme").and_then(theme))
.or_else(|| attr(n, "hAnsi"))
.map(font_name)?;
if name.is_empty() {
return None;
}
if let Some(&i) = self.font_numbers.get(&name) {
return Some(i);
}
if self.fonts.len() >= MAX_FONTS {
return None;
}
self.fonts.push(name.clone());
self.font_numbers.insert(name, self.fonts.len() - 1);
Some(self.fonts.len() - 1)
}
/// The run properties of a `w:rPr` element.
fn run_props(&mut self, rpr: Node) -> RunProps {
let mut p = RunProps::default();
for n in rpr.children() {
match local(n) {
Some("rFonts") => p.font = self.font(n),
Some("b") => p.bold = Some(on(n)),
Some("i") => p.italic = Some(on(n)),
Some("caps") => p.caps = Some(on(n)),
Some("smallCaps") => p.small_caps = Some(on(n)),
Some("strike") => p.strike = Some(on(n)),
Some("dstrike") => p.dstrike = Some(on(n)),
Some("vanish") => p.hidden = Some(on(n)),
Some("u") => p.underline = Some(attr(n, "val") != Some("none")),
Some("vertAlign") => {
p.script = match attr(n, "val") {
Some("superscript") => Some(Script::Super),
Some("subscript") => Some(Script::Sub),
Some("baseline") => Some(Script::Normal),
_ => p.script,
}
}
Some("position") => p.shift = half_points(n).or(p.shift),
Some("sz") => p.half_points = half_points(n).filter(|v| *v > 0.0).or(p.half_points),
Some("color") => {
let hex = |v: &str| -> Option<[u8; 3]> {
let v = v.trim();
if v.len() != 6 || !v.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let byte = |i: usize| v.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
Some([byte(0)?, byte(2)?, byte(4)?])
};
p.color = match attr(n, "val") {
Some("auto") => Some(None),
Some(v) => hex(v).map(Some).or(p.color),
None => p.color,
}
}
_ => {}
}
}
p
}
/// The paragraphs, tables and containers of them that are `n`'s children.
fn block(&mut self, n: Node, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in n.children() {
if self.full {
return;
}
match local(c) {
Some("p") => self.paragraph(c, depth),
Some("tbl") => self.table(c, depth),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
self.block(content, depth + 1);
}
}
Some("customXml" | "ins" | "moveTo") => self.block(c, depth + 1),
_ => {}
}
}
}
/// A table: each row a paragraph, its cells separated by tabs.
fn table(&mut self, tbl: Node, depth: usize) {
for row in items(tbl, "tr", depth) {
for cell in items(row, "tc", depth) {
self.flush();
self.block(cell, depth + 1);
// The cell's last paragraph goes on in the next cell, after a tab.
let end = self.pending.take();
if self.cell_tab
&& let Some((_, mark)) = end
{
self.push('\t', mark);
}
self.cell_end = end.or(self.cell_end);
self.cell_tab = true;
}
self.cell_tab = false;
self.pending = self.cell_end.take();
}
}
/// A paragraph.
fn paragraph(&mut self, p: Node, depth: usize) {
self.flush();
let ppr = child(p, "pPr");
let style = ppr.and_then(|n| child(n, "pStyle")).and_then(|n| attr(n, "val")).map(str::to_string).or_else(|| self.default_style.clone());
let mut para = Para::default();
para.apply(&self.default_para);
let mut base = Chars::default();
base.apply(&self.default_run, true);
for s in self.chain(style.as_deref()) {
para.apply(&s.para);
base.apply(&s.run, true);
}
if let Some(n) = ppr {
para.apply(&para_props(n));
}
(self.para, self.base) = (para, base);
self.inline(p, depth + 1);
// The paragraph mark's run properties are direct formatting over the paragraph style's.
let mut mark = base;
if let Some(rpr) = ppr.and_then(|n| child(n, "rPr")) {
let props = self.run_props(rpr);
mark.apply(&props, false);
}
// A line break at the paragraph's end is written before the paragraph ends, and a hidden
// paragraph mark joins the paragraph to the next.
if !mark.hidden {
self.flush();
self.pending = Some((para, mark));
}
}
/// The runs of a paragraph, and the containers of runs in it.
fn inline(&mut self, n: Node, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in n.children() {
match local(c) {
Some("r") => self.run(c),
Some("hyperlink" | "ins" | "moveTo" | "smartTag" | "customXml" | "fldSimple" | "dir" | "bdo") => self.inline(c, depth + 1),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
self.inline(content, depth + 1);
}
}
_ => {}
}
}
}
/// A run.
fn run(&mut self, r: Node) {
let rpr = child(r, "rPr");
let mut chars = self.base;
let style = rpr.and_then(|n| child(n, "rStyle")).and_then(|n| attr(n, "val")).or(self.default_char_style.as_deref());
for s in self.chain(style) {
chars.apply(&s.run, true);
}
if let Some(n) = rpr {
let props = self.run_props(n);
chars.apply(&props, false);
}
for c in r.children() {
match local(c) {
Some("t") => {
// Comments and processing instructions may split the text.
for ch in c.children().filter(|n| n.is_text()).filter_map(|n| n.text()).flat_map(str::chars) {
self.put(ch, chars);
}
}
Some("tab" | "ptab") => self.put('\t', chars),
Some("noBreakHyphen") => self.put('\u{2011}', chars),
Some("softHyphen") => self.put('\u{AD}', chars),
Some("br") if matches!(attr(c, "type"), None | Some("textWrapping")) => self.line_break(chars),
Some("cr") => self.line_break(chars),
Some("fldChar") => match attr(c, "fldCharType") {
Some("begin") if self.fields.len() < MAX_DEPTH => self.fields.push(true),
Some("begin") => self.field_overflow += 1,
Some("separate") if self.field_overflow == 0 => {
if let Some(instructions) = self.fields.last_mut() {
*instructions = false;
}
}
Some("end") if self.field_overflow > 0 => self.field_overflow -= 1,
Some("end") => {
self.fields.pop();
}
_ => {}
},
_ => {}
}
}
}
/// Text is not read: hidden, or a field's instructions.
fn skipped(&self, chars: &Chars) -> bool {
chars.hidden || self.field_overflow > 0 || self.fields.contains(&true)
}
/// Character `c` of the document's text in formatting `chars`.
fn put(&mut self, c: char, chars: Chars) {
if self.skipped(&chars) {
return;
}
self.flush();
if std::mem::take(&mut self.cell_tab) {
self.push('\t', chars);
}
self.push(c, chars);
}
/// A line break: the rest of the paragraph is a new paragraph in the same formatting.
fn line_break(&mut self, chars: Chars) {
if self.skipped(&chars) {
return;
}
self.flush();
self.pending = Some((self.para, chars));
}
/// Writes the break of the paragraph that has ended.
fn flush(&mut self) {
let Some((para, mark)) = self.pending.take() else { return };
if self.paras.len() >= MAX_PARAGRAPHS {
self.full = true;
return;
}
self.paras.push(para);
// A cell that starts with a break is still separated from the cell before it.
if std::mem::take(&mut self.cell_tab) {
self.push('\t', mark);
}
self.push('\n', mark);
}
/// Adds `c` to the text in formatting `chars`.
fn push(&mut self, c: char, chars: Chars) {
self.len += c.len_utf8();
if self.len > text::MAX_TEXT {
self.full = true;
return;
}
let runs = self.spans.len();
match self.spans.last_mut() {
Some((s, f)) if *f == chars => s.push(c),
_ if runs >= MAX_RUNS => self.full = true,
_ => self.spans.push((c.to_string(), chars)),
}
}
/// The character attributes of formatting `c`.
fn style(&self, c: &Chars) -> CharStyle {
let mut s = CharStyle::default();
if let Some(name) = c.font.and_then(|i| self.fonts.get(i)) {
s.font_family.clone_from(name);
}
s.font_style = match (c.bold, c.italic) {
(true, true) => "Bold Italic",
(true, false) => "Bold",
(false, true) => "Italic",
(false, false) => "Regular",
}
.into();
if let Some(v) = c.half_points {
s.size = (v / 2.0).clamp(0.1, 1296.0);
}
s.baseline_shift = (c.shift / 2.0).clamp(-1296.0, 1296.0);
if let Some([r, g, b]) = c.color {
s.fill = Paint::solid(vectorcraft_color::Color::rgb8(r, g, b));
}
s.underline = c.underline;
s.strikethrough = c.strike || c.dstrike;
s.all_caps = c.caps;
// Document Setup's proportions replace these when the type is placed.
s.small_caps = c.small_caps.then_some(70.0);
s.position = match c.script {
Script::Normal => CharPosition::Normal,
Script::Super => CharPosition::Superscript(ScriptMetrics::DEFAULT),
Script::Sub => CharPosition::Subscript(ScriptMetrics::DEFAULT),
};
s
}
/// The text read, without blank paragraphs at its end, and each paragraph's attributes.
fn finish(mut self, cmd: &str) -> Result<Story> {
if self.full {
return Err(bad(
cmd,
format!(
"the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {} MB of text)",
text::MAX_TEXT >> 20
),
));
}
let last = self.pending.take().map_or(self.para, |(para, _)| para);
self.paras.push(last);
let mut all: String = self.spans.iter().map(|(s, _)| s.as_str()).collect();
while self.paras.len() > 1
&& let Some(at) = all.rfind('\n')
&& all.get(at + 1..).is_some_and(|tail| tail.trim().is_empty())
{
all.truncate(at);
self.paras.pop();
}
let mut keep = all.len();
let mut runs: Vec<TextRun> = vec![];
for (s, chars) in &self.spans {
let piece = s.get(..keep.min(s.len())).unwrap_or_default();
keep -= piece.len();
if piece.is_empty() {
continue;
}
let style = self.style(chars);
match runs.last_mut() {
Some(r) if r.style == style => r.text.push_str(piece),
_ => runs.push(TextRun::new(piece, style)),
}
}
let points = |v: f64| (v / 20.0).clamp(-1296.0, 1296.0);
let paras = self
.paras
.iter()
.map(|p| {
let rtl = p.rtl == Some(true);
let (left, right) = if rtl { (p.end, p.start) } else { (p.start, p.end) };
ParaStyle {
justify: match p.align {
Align::Left => Justify::Left,
Align::Center => Justify::Center,
Align::Right => Justify::Right,
Align::Both if rtl => Justify::JustifyRight,
Align::Both => Justify::JustifyLeft,
Align::Distribute => Justify::JustifyAll,
Align::Start => Justify::Auto,
Align::End if rtl => Justify::Left,
Align::End => Justify::Right,
},
left_indent: points(left),
right_indent: points(right),
first_line_indent: points(p.first),
space_before: points(p.before),
space_after: points(p.after),
direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }),
..ParaStyle::default()
}
})
.collect();
Ok(Story { runs, paras })
}
}
/// Font name `name` trimmed, and cut at the last character that ends within [`MAX_NAME`] bytes.
fn font_name(name: &str) -> String {
let name = name.trim();
let end = (0..=MAX_NAME.min(name.len())).rev().find(|&i| name.is_char_boundary(i)).unwrap_or_default();
name.get(..end).unwrap_or_default().to_string()
}
/// The paragraph properties of a `w:pPr` element.
fn para_props(ppr: Node) -> ParaProps {
let mut p = ParaProps::default();
for n in ppr.children() {
match local(n) {
Some("jc") => {
p.align = match attr(n, "val") {
Some("left") => Some(Align::Left),
Some("center") => Some(Align::Center),
Some("right") => Some(Align::Right),
Some("both" | "lowKashida" | "mediumKashida" | "highKashida") => Some(Align::Both),
Some("distribute" | "thaiDistribute") => Some(Align::Distribute),
Some("start") => Some(Align::Start),
Some("end") => Some(Align::End),
_ => p.align,
}
}
Some("ind") => {
// `w:left` and `w:right` (Transitional) are the start and end indents too.
p.start = twips(n, "start").or_else(|| twips(n, "left")).or(p.start);
p.end = twips(n, "end").or_else(|| twips(n, "right")).or(p.end);
// A hanging indent wins over a first-line indent.
p.first = twips(n, "hanging").map(|v| -v).or_else(|| twips(n, "firstLine")).or(p.first);
}
Some("spacing") => {
p.before = twips(n, "before").or(p.before);
p.after = twips(n, "after").or(p.after);
}
Some("bidi") => p.bidi = Some(on(n)),
_ => {}
}
}
p
}
/// `n`'s children named `name`, and those inside content controls and custom XML among them.
fn items<'a, 'i>(n: Node<'a, 'i>, name: &str, depth: usize) -> Vec<Node<'a, 'i>> {
let mut out = vec![];
if depth > MAX_DEPTH {
return out;
}
for c in n.children() {
match local(c) {
Some(l) if l == name => out.push(c),
Some("sdt") => {
if let Some(content) = child(c, "sdtContent") {
out.extend(items(content, name, depth + 1));
}
}
Some("customXml") => out.extend(items(c, name, depth + 1)),
_ => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn measures_read_numbers_and_units() {
assert_eq!(measure("240", 20.0), Some(240.0));
assert_eq!(measure("12pt", 20.0), Some(240.0));
assert_eq!(measure("1in", 2.0), Some(144.0));
assert_eq!(measure("2.54cm", 1.0).map(|v| (v * 1e9).round() / 1e9), Some(72.0));
assert_eq!(measure("1e999", 20.0), None);
assert_eq!(measure("wide", 20.0), None);
}
#[test]
fn nesting_counts_elements_outside_markup_that_is_not_an_element() {
assert_eq!(nesting(r#"<?xml version="1.0"?><a><b x="1>2" y='</c>'><c/></b><!-- <d><e> --><![CDATA[<f><g>]]></a>"#), 2);
assert_eq!(nesting("<a><b><c>"), 3, "unclosed elements count");
assert_eq!(nesting("</a></b><a>"), 1);
assert_eq!(nesting("<a x=\"unterminated"), 1);
assert_eq!(nesting(""), 0);
}
#[test]
fn targets_resolve_against_their_folder() {
assert_eq!(resolve("", "word/document.xml"), "word/document.xml");
assert_eq!(resolve("word/", "styles.xml"), "word/styles.xml");
assert_eq!(resolve("word/", "../customXml/item1.xml"), "customXml/item1.xml");
assert_eq!(resolve("word/", "/word/theme/./theme1.xml"), "word/theme/theme1.xml");
}
}
+36 -11
View File
@@ -17,6 +17,8 @@
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
//! [`text`]).
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
//! - A Word document (`.docx`) becomes area type with the character and paragraph formatting its
//! styles and direct formatting give ([`docx`]).
//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document:
//! one locked object showing the artboard `page`, linked to the file and read again when it
//! changes ([`document`]); without Link, an editable copy of its art.
@@ -28,6 +30,7 @@
pub(crate) mod adopt;
pub(crate) mod document;
mod docx;
mod rtf;
mod text;
@@ -35,7 +38,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling};
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling, SymbolBoxes};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_pdf::CropTo;
@@ -59,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
has_doc,
place
),
@@ -98,7 +101,8 @@ pub fn specs() -> Vec<CommandSpec> {
/// A file's art at its natural size, before it joins a document.
enum Art {
Image(RasterImage),
/// A text or RTF file's text, placed as area type filling the natural box.
/// A text file's, an RTF file's or a Word document's text, placed as area type filling the
/// natural box.
Text(text::Story),
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
/// bounds (not the artboard) are shown.
@@ -155,6 +159,14 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
}
return Ok(Loaded { name, format: &rtf::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
}
// A Word document is recognized by its name or by its package's `word/document.xml`.
if docx::is_docx(src.name, &src.bytes) {
let story = docx::import(&src.bytes, cmd)?;
if story.plain_text().trim().is_empty() {
return Err(bad(cmd, format!("`{name}` has no text to place")));
}
return Ok(Loaded { name, format: &docx::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
}
let format = fileio::detect(src.name, &src.bytes)
.ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?;
let page = match p.get("page") {
@@ -227,6 +239,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
doc.drop_edit_modes();
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
let layers = std::mem::take(&mut doc.layers);
let symbols = doc.symbol_boxes();
let (nodes, clip) = if matches!(format.id, "svg" | "svgz" | "dxf") {
// An SVG with `crop: "crop"` asked for is framed and clipped by its viewBox (its artboard),
// as a PDF by its page: art drawn on a shared canvas keeps its place and scale (#788). By
@@ -241,16 +254,28 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
// A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard.
let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] };
(on_artboard(pages, board), crop.then_some(board))
(on_artboard(&symbols, pages, board), crop.then_some(board))
};
let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())));
let natural = clip.or_else(|| shown_bounds(&symbols, &nodes));
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
}
/// The placeable art of `layers` that lies on `board`.
fn on_artboard(layers: &[Arc<Node>], board: Rect) -> Vec<Node> {
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
/// The bounds of what `nodes` draw, as an export measures art
/// ([`vectorcraft_render::encode::drawn_bounds`]): hidden objects, guides and template layers
/// left out, symbol instances measured by their art in `symbols`.
pub(crate) fn shown_bounds(symbols: &SymbolBoxes, nodes: &[Node]) -> Option<Rect> {
nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, vectorcraft_render::encode::drawn_bounds(symbols, n)))
}
/// The placeable art of `layers` that lies on `board`: each object whose drawn bounds
/// ([`vectorcraft_render::encode::drawn_bounds`]) lie on it, or, for an object that draws nothing,
/// each object whose visual bounds lie on it.
fn on_artboard(symbols: &SymbolBoxes, layers: &[Arc<Node>], board: Rect) -> Vec<Node> {
let on_board = |c: &&Arc<Node>| {
let b = vectorcraft_render::encode::drawn_bounds(symbols, c).or_else(|| c.visual_bounds_with(symbols));
b.is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()))
};
art_of(layer_art(layers).filter(on_board))
}
@@ -364,7 +389,7 @@ fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool) -> Affine
return im.xf * full.inverse() * Affine::translate(centre - natural.center());
}
}
let c = old.geometric_bounds().map_or(natural.center(), |b| b.center());
let c = old.geometric_bounds_with(&d.symbol_boxes()).map_or(natural.center(), |b| b.center());
Affine::translate(c - natural.center())
}
@@ -399,7 +424,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
// Type reflows rather than scales: its frame takes the size it is placed at.
let is_text = matches!(loaded.art, Art::Text(_));
if is_text {
let size = match (old_node.and_then(Node::geometric_bounds), rect) {
let size = match (old_node.and_then(|o| o.geometric_bounds_with(&st.doc.symbol_boxes())), rect) {
(Some(b), _) => b.size(),
(None, Some(r)) => r.size(),
(None, None) => text_frame(&st.doc, at).size(),
@@ -424,7 +449,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
let (name, format, warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings));
let (id, size) = s.edit("Place", |d, sel| {
let node = transformed(build(d, loaded, link), m);
let size = node.geometric_bounds().unwrap_or_default();
let size = node.geometric_bounds_with(&d.symbol_boxes()).unwrap_or_default();
let (into, index) = match old {
Some(old) => {
let (par, idx, _) = d.position(old).ok_or(EngineError::NoNode(old))?;
+19 -1
View File
@@ -14,6 +14,8 @@
//! superscript and subscript, baseline shift (`\up`, `\dn`) and the color table's color (`\cf`).
//! - Paragraph formatting, reset by `\pard`: alignment (`\ql`, `\qc`, `\qr`, `\qj`, `\qd`), left,
//! right and first-line indents, space before and after, and direction (`\rtlpar`, `\ltrpar`).
//! `\ql` and `\qr` are the left and right edges in either direction, and a justified (`\qj`)
//! paragraph marked right to left (`\rtlpar`) ends its last line on the right.
//!
//! Other control words are skipped, and so are hidden text (`\v`) and the groups of destinations
//! that are not the document's text: every `\*` destination, the style sheet, document
@@ -608,7 +610,11 @@ impl Reader {
.paras
.iter()
.map(|p| ParaStyle {
justify: p.justify,
// `\qj` in an `\rtlpar` paragraph justifies it with its last line on the right.
justify: match p.justify {
Justify::JustifyLeft if p.rtl == Some(true) => Justify::JustifyRight,
j => j,
},
left_indent: twips(p.left),
right_indent: twips(p.right),
first_line_indent: twips(p.first),
@@ -682,6 +688,18 @@ mod tests {
assert_eq!(s.paras.last().map(|p| p.left_indent), Some(20.0));
}
#[test]
fn justified_right_to_left_paragraphs_end_their_last_line_on_the_right() {
let s = read(concat!(
r"{\rtf1\pard\rtlpar\qj a\par\pard\qj\rtlpar b\par\pard\qj c\par\pard\rtlpar\ltrpar\qj d\par",
r"\pard\rtlpar\ql e\par\pard\rtlpar\qr f\par\pard\rtlpar\qc g\par\pard\rtlpar\qd h}"
));
let justify: Vec<Justify> = s.paras.iter().map(|p| p.justify).collect();
use Justify::*;
assert_eq!(justify, [JustifyRight, JustifyRight, JustifyLeft, JustifyLeft, Left, Right, Center, JustifyAll]);
assert_eq!(s.paras.first().and_then(|p| p.direction), Some(ParaDirection::RightToLeft));
}
#[test]
fn deep_nesting_and_stray_braces_end_cleanly() {
let deep = format!("{{\\rtf1 {}x{}}}", "{".repeat(5000), "}".repeat(5000));
+3 -2
View File
@@ -17,7 +17,8 @@ pub(super) const FORMAT: fileio::Format = fileio::Format {
options: &[],
};
/// The most bytes a text or RTF file placed as type may have.
/// The most bytes a text or RTF file placed as type may have, and the most bytes of text a Word
/// document may give.
pub(super) const MAX_TEXT: usize = 4 << 20;
/// Text Import Options (`file.place`'s `text` param).
@@ -190,7 +191,7 @@ fn tabs_for_spaces(s: &str, n: usize) -> String {
out
}
/// The text placed type gets from a text or RTF file.
/// The text placed type gets from a text file, an RTF file or a Word document.
pub(super) struct Story {
/// The text in runs of one character formatting; paragraphs are separated by `\n`.
pub(super) runs: Vec<vectorcraft_doc::TextRun>,
+4 -2
View File
@@ -89,8 +89,10 @@ pub const GPU_PREFERENCES: &[(&str, &str)] =
&[("automatic", "Automatic"), ("lowPower", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
/// Performance › Linux Display Server (`linuxDisplayServer`), read by the desktop app at startup:
/// `x11` opens the window through XWayland on a Wayland session, where dropped files arrive
/// (winit 0.30 has no Wayland drag and drop, #1151).
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] = &[("automatic", "Automatic"), ("x11", "X11 (XWayland)")];
/// (winit 0.30 has no Wayland drag and drop, #1151); `x11WithPen` does the same only when a pen is
/// attached as the app starts: under XWayland a drawing tablet's pen draws as a mouse (#2028).
const LINUX_DISPLAY_SERVERS: &[(&str, &str)] =
&[("automatic", "Automatic"), ("x11", "X11 (XWayland)"), ("x11WithPen", "X11 (XWayland) When a Pen Is Attached")];
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
+1 -1
View File
@@ -456,7 +456,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
let look = GraphicStyle::of(String::new(), captured(n), n);
let name = d.graphic_styles[i].name.clone();
let chars = s.prefs.override_char_color;
s.edit(&format!("Redefine Graphic Style \u{201c}{name}\u{201d}"), |d, _| {
s.edit_named("Redefine Graphic Style “{name}”", &name, false, |d, _| {
let id = d.graphic_style_id(i);
let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look });
// Objects still linked follow the new definition; edited ones are unlinked (they keep
+41 -10
View File
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Color Group",
["Window", "Swatches"],
None,
"{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches), fromArtwork?: false (add the unique colours of the selected art: fills, strokes, text, gradient stops and mesh points; a colour linked to a global swatch moves that swatch into the group), toGlobal?: true (with fromArtwork: the new swatches are global and the selected art's matching unlinked colours link to them), includeTints?: false (with fromArtwork: tints of global swatches also get swatches of their own)} → {name, swatches: [names in the group], linked: paints linked}",
"{name?, swatches?: [names] (solid colours move into the group; gradients, patterns and None stay), colors?: [colour] (added as new swatches), keepModel?: bool (as paint.setFill), fromArtwork?: false (add the unique colours of the selected art: fills, strokes, text, gradient stops and mesh points; a colour linked to a global swatch moves that swatch into the group), toGlobal?: true (with fromArtwork: the new swatches are global and the selected art's matching unlinked colours link to them), includeTints?: false (with fromArtwork: tints of global swatches also get swatches of their own)} → {name, swatches: [names in the group], linked: paints linked}",
has_doc,
swatch_new_group
),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Swatch Options",
["Window", "Swatches"],
None,
"{name, newName?, color? (e.g. {l, a, b} for a Lab colour), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour; gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}",
"{name, newName?, color? (e.g. {l, a, b} for a Lab colour), keepModel?: bool (as paint.setFill; the swatch's own color and a color given with mode keep their model), mode?: \"gray\"|\"rgb\"|\"hsb\"|\"lab\"|\"cmyk\"|\"web\" (convert the colour), global?, spot? (spot colours are always global), paint?: paint.setFill params ({color}, {gradient}, {swatch} or {pattern}) replacing the swatch's colour; gradient or pattern with one of the same kind} edit a swatch in any colour group, as one undo step. Fills, strokes, text, gradient stops and tint swatches linked to a global swatch take its new colour (at their own tint) and name; turning Global off unlinks them (they keep their colour). Colour, mode and spot apply to solid colours only; a tint swatch only takes a new name (edit its base) → {name, relinked: paints changed}",
has_doc,
swatch_edit
),
@@ -127,7 +127,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Color Group",
["Window", "Swatches"],
None,
"{group: colour group, colors: [colour] (hex, key, {c,m,y,k} or {l,a,b}; the group's swatches take them in order, extra colours become new swatches, swatches past the end are removed; built-in swatches can't change), rename?: new group name} rewrite a colour group as one undo step; art linked to its global swatches takes their new colours (and keeps its colour, unlinked, when its swatch goes) → {name, swatches: [names], relinked}",
"{group: colour group, colors: [colour] (hex, key, {c,m,y,k} or {l,a,b}; the group's swatches take them in order, extra colours become new swatches, swatches past the end are removed; built-in swatches can't change; a color the swatch already has keeps its model), keepModel?: bool (as paint.setFill), rename?: new group name} rewrite a colour group as one undo step; art linked to its global swatches takes their new colours (and keeps its colour, unlinked, when its swatch goes) → {name, swatches: [names], relinked}",
has_doc,
swatch_edit_group
),
@@ -136,10 +136,15 @@ pub fn specs() -> Vec<CommandSpec> {
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.new";
let mode = str_param(p, "mode");
let paints = match (p.get("colors").and_then(Value::as_array), str_param(p, "pattern")) {
(Some(cs), _) => {
let cs: Vec<Paint> =
cs.iter().map(|c| color_value(c).map(Paint::solid)).collect::<Option<_>>().ok_or_else(|| bad(C, "bad colour in `colors`"))?;
let stored = |c: Color| if mode.is_some() { c } else { super::paint::new_color(s, p, c) };
let cs: Vec<Paint> = cs
.iter()
.map(|c| color_value(c).map(|c| Paint::solid(stored(c))))
.collect::<Option<_>>()
.ok_or_else(|| bad(C, "bad colour in `colors`"))?;
if cs.is_empty() {
return Err(bad(C, "`colors` is empty"));
}
@@ -148,7 +153,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
(None, Some(name)) if s.doc()?.doc.pattern(name).is_some() => vec![pattern_paint(name)],
(None, Some(name)) => return Err(bad(C, format!("no pattern `{name}`"))),
(None, None) => {
let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone());
let paint = match mode {
Some(_) => paint_from(s, &keeping_model(p))?,
None => paint_from(s, p)?,
}
.unwrap_or_else(|| s.paint.fill.clone());
// `tint` alone: that tint of the global colour the current fill links to.
match (super::paint::tint_param(p, C)?, &paint) {
(Some(t), Paint::Solid { swatch: Some(l), .. }) if p.get("swatch").is_none() => {
@@ -161,7 +170,6 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
};
let spot = bool_or(p, "spot", false);
let global = spot || bool_or(p, "global", false);
let mode = str_param(p, "mode");
let group = str_param(p, "group");
let paints = paints
.into_iter()
@@ -294,7 +302,11 @@ fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> u
fn swatch_new_group(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "swatch.newGroup";
let mut names = str_list(p, "swatches");
let colors: Vec<Color> = p.get("colors").and_then(Value::as_array).map(|a| a.iter().filter_map(color_value).collect()).unwrap_or_default();
let colors: Vec<Color> = p
.get("colors")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(color_value).map(|c| super::paint::new_color(s, p, c)).collect())
.unwrap_or_default();
let from_art = bool_or(p, "fromArtwork", false);
let to_global = from_art && bool_or(p, "toGlobal", true);
let tints = bool_or(p, "includeTints", false);
@@ -448,6 +460,16 @@ fn convert_to_mode(c: Color, mode: &str, cmd: &str) -> Result<Color> {
})
}
/// Paint params `p` with `keepModel: true`, for a color given with a `mode`: [`convert_to_mode`]
/// converts it, and the document's color mode does not convert it first.
fn keeping_model(p: &Value) -> Value {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("keepModel".into(), Value::Bool(true));
}
q
}
/// How a swatch edit reaches the solid paints linked to swatch `from`.
struct Relink {
from: String,
@@ -518,7 +540,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
}
// A replacement paint of the swatch's kind (a dropped colour or edited gradient).
let paint = match p.get("paint").filter(|v| !v.is_null()) {
Some(v) => match (paint_from(s, v)?, &sw.paint) {
Some(v) => match (if given("mode") { paint_from(s, &keeping_model(v))? } else { paint_from(s, v)? }, &sw.paint) {
(Some(Paint::Solid { color, .. }), Paint::Solid { .. }) => Some(Paint::solid(color)),
(Some(Paint::Gradient(g)), Paint::Gradient(_)) => {
Some(Paint::Gradient(Box::new(vectorcraft_color::GradientPaint { geom: None, swatch: None, ..*g })))
@@ -529,7 +551,11 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
};
let mut color = match p.get("color").filter(|v| !v.is_null()) {
Some(v) => Some(color_value(v).ok_or_else(|| bad(C, format!("bad color {v}")))?),
Some(v) => {
let c = color_value(v).ok_or_else(|| bad(C, format!("bad color {v}")))?;
// The swatch's own color, and a color given with a mode, keep their model.
Some(if given("mode") || solid == Some(c) { c } else { super::paint::new_color(s, p, c) })
}
None => paint.as_ref().and_then(Paint::color).or(solid),
};
if let (Some(c), Some(m)) = (color, str_param(p, "mode")) {
@@ -849,6 +875,11 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
.iter()
.map(|v| color_value(v).ok_or_else(|| bad(C, format!("bad colour {v}"))))
.collect::<Result<_>>()?;
// A color its swatch already has keeps its model. The others take the document's.
let old: Vec<Option<Color>> =
s.doc()?.doc.swatch_groups.iter().find(|g| g.name == group).map(|g| g.swatches.iter().map(|w| w.paint.color()).collect()).unwrap_or_default();
let colors: Vec<Color> =
colors.into_iter().enumerate().map(|(i, c)| if old.get(i) == Some(&Some(c)) { c } else { super::paint::new_color(s, p, c) }).collect();
let rename = str_param(p, "rename");
let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?;
e.defaults(s);
+13 -2
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
has_doc,
set_range_style
),
@@ -54,7 +54,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, bounds: {x, y, width, height} (as text.create), 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?, keepModel? (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
),
@@ -421,6 +421,16 @@ impl CharChange {
Ok(c)
}
/// The `fill` and `stroke` colors as the document stores them: an RGB color in a CMYK
/// document becomes CMYK through the color settings ([`super::paint::new_color`]).
fn store_colors(&mut self, s: &Session, p: &Value) {
for paint in [&mut self.fill, &mut self.stroke].into_iter().flatten() {
if let Paint::Solid { color, .. } = paint {
*color = super::paint::new_color(s, p, *color);
}
}
}
pub(crate) fn is_empty(&self) -> bool {
self.font.is_none()
&& self.style.is_none()
@@ -600,6 +610,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
},
};
let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?;
change.store_colors(s, p);
if change.is_empty() {
return Err(bad(C, "nothing to change"));
}
+70 -9
View File
@@ -58,7 +58,7 @@ macro_rules! style_cmds {
concat!($p, ".new"),
concat!("New ", $what, " Style"),
&["Window", "Type", $menu],
"{name?, attrs?: {…} (default: selected text), id?, start?: byte, end?: byte} → {name}; a nonempty character range samples its first selected character; start alone keeps insertion affinity; paragraph styles sample start",
"{name? (trimmed, as rename trims `to`), attrs?: {…} (default: selected text), id?, start?: byte, end?: byte} → {name}; a nonempty character range samples its first selected character; start alone keeps insertion affinity; paragraph styles sample start",
|s, p| new(s, p, $kind),
true,
),
@@ -164,12 +164,13 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
}
/// 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
/// JSON leaves out at their defaults (the Hyphenation and Justification 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)),
("justification", json!(v.justification)),
("composer", json!(v.composer)),
("tabs", json!(v.tabs)),
("mojikumi", json!(v.mojikumi)),
@@ -177,6 +178,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
("leading_model", json!(v.leading_model)),
("burasagari", json!(v.burasagari)),
("kinsoku", json!(v.kinsoku)),
("roman_hanging", json!(v.roman_hanging)),
] {
m.insert(k.into(), x);
}
@@ -431,14 +433,17 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let d = &s.doc()?.doc;
let base = if kind == Kind::Char { "Character Style" } else { "Paragraph Style" };
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
Some(n) => n.trim().to_string(),
None => (1..).map(|i| format!("{base} {i}")).find(|n| kind.attrs(d, n).is_none()).unwrap_or_else(|| base.to_string()),
};
if name.trim().is_empty() || kind.attrs(d, &name).is_some() {
if name.is_empty() {
return Err(bad(&c, "give the style a name"));
}
if kind.attrs(d, &name).is_some() {
return Err(bad(&c, format!("a style named `{name}` already exists")));
}
let n2 = name.clone();
s.edit(&format!("New {base}"), |d, _| {
s.edit(if kind == Kind::Char { "New Character Style" } else { "New Paragraph Style" }, |d, _| {
kind.defs_mut(d).push(TextStyleDef { name: n2, attrs });
Ok(())
})?;
@@ -474,7 +479,7 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
Ok(st)
};
let count = ids.len();
s.edit(&format!("Apply {name}"), |d, _| {
s.edit_named("Apply {name}", &name, name == kind.normal(), |d, _| {
for id in &ids {
let Some(t) = text_mut(d, *id) else { continue };
match kind {
@@ -524,7 +529,7 @@ fn redefine_to(s: &mut Session, kind: Kind, name: &str, new_attrs: Map<String, V
let d = &s.doc()?.doc;
let old = kind.attrs(d, name).ok_or_else(|| bad(&kind.cmd(verb), format!("no style `{name}`")))?;
let name = name.to_string();
s.edit(&format!("Redefine {name}"), |d, _| {
s.edit_named("Redefine {name}", &name, name == kind.normal(), |d, _| {
match kind.defs_mut(d).iter_mut().find(|s| s.name == name) {
Some(def) => def.attrs = new_attrs.clone(),
None => kind.defs_mut(d).push(TextStyleDef { name: name.clone(), attrs: new_attrs.clone() }),
@@ -551,10 +556,12 @@ fn duplicate(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let name = name_param(p, kind, "duplicate")?.to_string();
let d = &s.doc()?.doc;
let attrs = kind.attrs(d, &name).ok_or_else(|| bad(&kind.cmd("duplicate"), format!("no style `{name}`")))?;
// `new` trims the name, so the copy is named from the trimmed one.
let stem = name.trim();
let copy = (1..)
.map(|i| if i == 1 { format!("{name} copy") } else { format!("{name} copy {i}") })
.map(|i| if i == 1 { format!("{stem} copy") } else { format!("{stem} copy {i}") })
.find(|n| kind.attrs(d, n).is_none())
.unwrap_or_else(|| format!("{name} copy"));
.unwrap_or_else(|| format!("{stem} copy"));
new(s, &json!({ "name": copy, "attrs": attrs }), kind)
}
@@ -658,6 +665,37 @@ mod tests {
assert_eq!(list["styles"][1]["uses"], 1);
}
/// Applying and redefining a style record a step named from a template with the style's name
/// in it (`HistoryEntry::named`), and undo and redo keep it. The Normal styles' names are
/// marked as names VectorCraft gives. A new style's step has a name of its own.
#[test]
fn style_steps_are_named_from_a_template() {
let (mut s, id) = session_with_text("Hello");
let last = |s: &Session| {
let e = s.doc().unwrap().history.undo.last().unwrap();
(e.label.clone(), e.named().map(|n| (n.template, n.name.clone(), n.built_in)))
};
let named = |template, name: &str, built_in| Some((template, name.to_string(), built_in));
s.execute("charStyle.new", &json!({"name": "Body"})).unwrap();
assert_eq!(last(&s), ("New Character Style".into(), None));
s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap();
assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false)));
s.execute("edit.undo", &json!({})).unwrap();
let redo = s.doc().unwrap().history.redo.last().unwrap().named().cloned();
assert_eq!(redo.map(|n| n.label()).as_deref(), Some("Apply Body"));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false)));
s.execute("charStyle.apply", &json!({"name": NORMAL_CHAR, "id": id})).unwrap();
assert_eq!(last(&s), (format!("Apply {NORMAL_CHAR}"), named("Apply {name}", NORMAL_CHAR, true)));
s.execute("paraStyle.new", &json!({"name": "Lead"})).unwrap();
assert_eq!(last(&s), ("New Paragraph Style".into(), None));
s.execute("paraStyle.setAttrs", &json!({"name": "Lead", "attrs": {"direction": "rightToLeft"}})).unwrap();
assert_eq!(last(&s), ("Redefine Lead".into(), named("Redefine {name}", "Lead", false)));
// A step renamed since it was recorded is no longer named from the template.
s.doc_mut().unwrap().history.undo.last_mut().unwrap().label = "Typing".into();
assert_eq!(last(&s), ("Typing".into(), None));
}
#[test]
fn new_from_selection_rename_delete_and_undo() {
let (mut s, id) = session_with_text("Title");
@@ -687,6 +725,29 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill);
}
/// New Character Style and New Paragraph Style trim the name they are given, as a rename
/// trims `to`; the trimmed name is the one compared with the styles there are.
#[test]
fn new_styles_trim_their_names_as_a_rename_does() {
let (mut s, _) = session_with_text("Body text");
for p in ["charStyle", "paraStyle"] {
assert_eq!(s.execute(&format!("{p}.new"), &json!({"name": " Body \t"})).unwrap()["name"], "Body", "{p}");
let list = s.execute(&format!("{p}.list"), &json!({})).unwrap();
let names: Vec<&str> = list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect();
assert_eq!(names[1..], ["Body"], "{p}");
let e = s.execute(&format!("{p}.new"), &json!({"name": " Body"})).unwrap_err().to_string();
assert!(e.contains("a style named `Body` already exists"), "{p}: {e}");
let e = s.execute(&format!("{p}.new"), &json!({"name": " \t "})).unwrap_err().to_string();
assert!(e.contains("give the style a name"), "{p}: {e}");
assert_eq!(s.execute(&format!("{p}.duplicate"), &json!({"name": "Body"})).unwrap()["name"], "Body copy", "{p}");
}
// A style whose name has spaces around it, as a saved file can hold, is copied under the
// trimmed name, numbered past the copies there are.
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: " Old ".into(), attrs: Map::new() });
s.execute("charStyle.new", &json!({"name": "Old copy"})).unwrap();
assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2");
}
/// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no
/// mojikumi) are still attributes a style can set, and a style captured with one applies to
/// text at the default, is duplicated and comes back out of a library.
+165 -2
View File
@@ -137,6 +137,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
}
}
}
let optical = if live.len() >= 2 { optical_margin(before, doc, &live) } else { None };
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
let refs: Vec<&TextObject> = frames.iter().collect();
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story, &paras);
@@ -145,10 +146,12 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
let mut next = (**t).clone();
next.runs = part.runs;
next.set_paragraph_styles(part.paras);
if next.runs != t.runs || next.para != t.para || next.paras != t.paras {
let optical = optical.unwrap_or(t.optical_margin);
if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin {
t.runs = next.runs;
t.para = next.para;
t.paras = next.paras;
t.optical_margin = optical;
refresh_bounds(t);
}
}
@@ -160,6 +163,44 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
doc.text_threads = kept;
}
/// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes:
/// that of the first frame whose setting changed between `before` and `doc`, else the first frame's.
fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option<bool> {
let now = |id: &NodeId| text_of(doc, *id).map(|t| t.optical_margin);
let changed = live.iter().find(|id| text_of(before, **id).is_some_and(|t| Some(t.optical_margin) != now(id)));
changed.or(live.first()).and_then(now)
}
/// Give every frame of each thread the first frame's Optical Margin Alignment setting, without
/// re-flowing the story: for a document restored from a file as it was saved.
pub(crate) fn share_optical_margin(doc: &mut Document) {
for thread in doc.text_threads.clone() {
let live: Vec<NodeId> = thread.into_iter().filter(|id| text_of(doc, *id).is_some()).collect();
let Some(on) = optical_margin(doc, doc, &live) else { continue };
for id in &live {
if let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
&& t.optical_margin != on
{
t.optical_margin = on;
refresh_bounds(t);
}
}
}
}
/// `ids` followed by the other frames of the threads they are in, each once, in thread order.
pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = ids.to_vec();
for thread in d.text_threads.iter().filter(|t| t.iter().any(|id| ids.contains(id))) {
for id in thread {
if !out.contains(id) && text_of(d, *id).is_some() {
out.push(*id);
}
}
}
out
}
/// A closed path converted to an empty area-type frame with `style`.
fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle) -> Option<Node> {
let NodeKind::Path { path, .. } = &n.kind else { return None };
@@ -280,7 +321,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
}
None => d.text_threads.push(vec![from, to]),
}
// `to` is new or changed, so [`reflow`] flows the story into it after this edit.
// Force a re-flow of the thread on this edit: the edit leaves existing area type `to`
// unchanged.
touch(d, to);
sel.set([from, to]);
Ok(to)
})?;
@@ -550,6 +593,17 @@ mod tests {
assert_eq!(threads(&s), [[a, c, d]]);
}
/// Area type threaded into from an out port takes the overflow at once, before its own text.
#[test]
fn add_into_area_type_flows_the_story_into_it() {
let (mut s, a, _) = setup();
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Own text.", "area": {"width": 150, "height": 190}})).unwrap());
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
assert_eq!(threads(&s), [[a, c]]);
assert_eq!(format!("{}{}", text(&s, a), text(&s, c)), format!("{}Own text.", TEXT.repeat(4)));
assert!(text(&s, c).len() > "Own text.".len(), "the overflow flowed into the frame: {:?}", text(&s, c));
}
#[test]
fn add_refuses_bad_params_and_leaves_the_document_alone() {
let (mut s, a, b) = setup();
@@ -595,4 +649,113 @@ mod tests {
assert_eq!(text(&s, b), "");
assert_eq!(text(&s, a), TEXT.repeat(4), "the story returns to the remaining frame");
}
/// A story of one-line paragraphs that each start with a quotation mark, threaded from area
/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for.
fn quoted_thread() -> (Session, u64, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let story = "“Quoted.”\n".repeat(20);
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert!(text(&s, b).starts_with('“'));
(s, a, b)
}
/// Frame `id`'s Optical Margin Alignment setting, and whether its glyphs start left of its frame
/// (its lines' opening quotation marks hang).
fn optical(s: &Session, id: u64) -> (bool, bool) {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
(t.optical_margin, t.cached_bounds.unwrap().x0 < frame.bounds().unwrap().x0 - 1.0)
}
/// Optical Margin Alignment is a setting of the whole thread: Type › Optical Margin Alignment
/// in any frame sets it in every frame of the thread, in one undo step, and the lines in every
/// frame hang. Typing keeps it, and frames taken out of the thread keep it.
#[test]
fn optical_margin_alignment_is_set_for_every_frame_of_a_thread() {
let (mut s, a, b) = quoted_thread();
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let r = s.execute("type.opticalMarginAlignment", &json!({})).unwrap();
assert_eq!(r, json!({"on": true, "ids": [a, b]}));
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)), "the second frame's lines hang too");
// Turned off from the second frame, it is off in both, and one undo turns it back on in both.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false);
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
// Typing in the first frame re-flows the story and keeps the setting.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "“New.”\n"})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
// Any edit that sets one frame sets the thread.
s.edit("Set", |d, _| {
let Some(NodeKind::Text(t)) = d.node_mut(NodeId(b)).map(|n| &mut n.kind) else { panic!("not text") };
t.optical_margin = false;
refresh_bounds(t);
Ok(())
})
.unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((false, false), (false, false)));
s.execute("edit.undo", &json!({})).unwrap();
// Released or unthreaded, each frame keeps it.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, true));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
}
/// Threading type into a thread gives the joined thread the setting of its first frame.
#[test]
fn a_thread_takes_its_first_frames_optical_margin_alignment() {
let (mut s, a, b) = quoted_thread();
s.execute("text.thread.remove", &json!({})).unwrap();
s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false));
// Threaded again in stacking order: the second frame takes the first one's setting.
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
assert_eq!((optical(&s, a), optical(&s, b)), ((true, true), (true, true)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((optical(&s, a).0, optical(&s, b).0), (true, false));
// From `b`'s out port into `a`: `a` joins `b`'s thread after it and takes its setting.
s.execute("text.thread.add", &json!({"from": b, "to": a})).unwrap();
assert_eq!(threads(&s), [[b, a]]);
assert_eq!((optical(&s, a).0, optical(&s, b).0), (false, false));
// A new frame threaded from a frame takes the thread's setting.
s.execute("type.opticalMarginAlignment", &json!({"ids": [a], "on": true})).unwrap();
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 10]})).unwrap());
assert_eq!([a, b, c].map(|id| optical(&s, id).0), [true; 3]);
}
/// A file saved with the frames of one thread set differently opens with the first frame's
/// setting in every frame: a native file, and an EPS file restored from the document it carries.
#[test]
fn a_thread_saved_with_differing_frames_opens_with_the_first_frames_setting() {
let (mut s, a, b) = quoted_thread();
for (first, name) in [(true, "old.vectorcraft"), (false, "old.vectorcraft"), (true, "old.eps"), (false, "old.eps")] {
for (id, on) in [(a, first), (b, !first)] {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) else { panic!("not text") };
t.optical_margin = on;
}
let bytes = if name.ends_with(".eps") {
let r = s.execute("document.exportEps", &json!({})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
} else {
vectorcraft_format::save(&s.doc().unwrap().doc, false)
};
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
assert_eq!(r["restored"], name.ends_with(".eps"), "{r}");
assert_eq!(threads(&s), [[a, b]], "{name}");
assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}");
}
}
}
+97 -5
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Optical Margin Alignment",
["Type"],
None,
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}",
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}",
has_selection,
optical_margin_alignment
),
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
has_doc,
set_style
),
@@ -107,9 +107,9 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
}
/// Type › Optical Margin Alignment: set `on` (without it, the opposite of the first object's
/// setting) on every selected text object, in one undo step.
/// setting) on every selected text object and every frame of their threads, in one undo step.
fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
let ids = text_targets(s, p, "Optical Margin Alignment")?;
let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Optical Margin Alignment")?);
let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.optical_margin),
_ => None,
@@ -547,7 +547,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
let fill = match p.get("fill") {
None | Some(Value::Null) => None,
Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Some(vectorcraft_color::Paint::None),
Some(v) => Some(vectorcraft_color::Paint::solid(color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?)),
Some(v) => Some(vectorcraft_color::Paint::solid(super::paint::new_color(s, p, color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?))),
};
let features = super::textedit::features_param(p, C)?;
if font.is_none()
@@ -1061,6 +1061,98 @@ mod area_tests {
assert_eq!(old.hyphenation, HyphenationOptions::default());
}
/// Paragraph panel menu › Roman Hanging Punctuation: `text.setFormat {romanHangingPunctuation}`
/// sets it per paragraph in one undo step, the opening quotation mark then hangs left of the
/// frame, it is saved only when on, and paragraph styles carry it.
#[test]
fn roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s
.execute("text.create", &json!({"x": 10, "y": 10, "size": 40, "text": "“Quoted”\n“Two”", "area": {"width": 300, "height": 200}}))
.unwrap()["id"]
.as_u64()
.unwrap();
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("text"),
};
let left = |s: &Session| text(s).cached_bounds.unwrap().x0;
assert!(!text(&s).para_at(0).roman_hanging);
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("roman_hanging").is_none());
let inside = left(&s);
s.execute("select.set", &json!({"ids": [id]})).unwrap();
// Only the first paragraph (the caret's range).
s.execute("text.setFormat", &json!({"romanHangingPunctuation": true, "start": 0, "end": 0})).unwrap();
assert!(text(&s).para_at(0).roman_hanging && !text(&s).para_at(1).roman_hanging);
assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s));
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["roman_hanging"], true);
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap();
assert!(back.roman_hanging);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!text(&s).para_at(0).roman_hanging);
assert!((left(&s) - inside).abs() < 1e-9);
// A paragraph style carries it.
s.execute("paraStyle.new", &json!({"name": "Hung", "attrs": {"roman_hanging": true}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Hung", "id": id})).unwrap();
assert!(text(&s).para_at(1).roman_hanging);
}
/// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and
/// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their
/// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They
/// are saved only when not the defaults, and paragraph styles carry them.
#[test]
fn justification_options_are_set_per_paragraph_and_saved_only_when_not_default() {
use vectorcraft_doc::{JustificationOptions, SingleWordJustify};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]
.as_u64()
.unwrap();
let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),
_ => panic!("text"),
};
assert_eq!(paras(&s).0.justification, JustificationOptions::default());
assert!(serde_json::to_value(paras(&s).0).unwrap().get("justification").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute(
"text.setFormat",
&json!({
"wordSpacing": [60, 90, 2000], "letterSpacing": {"max": 10}, "glyphScaling": [95, 100, 105],
"autoLeading": 150, "singleWordJustification": "alignCenter", "start": 5, "end": 5
}),
)
.unwrap();
let want = JustificationOptions {
word_spacing: [60.0, 90.0, 1000.0],
letter_spacing: [0.0, 0.0, 10.0],
glyph_scaling: [95.0, 100.0, 105.0],
auto_leading: 150.0,
single_word: SingleWordJustify::AlignCenter,
};
let (first, second) = paras(&s);
assert_eq!((first.justification, &second.justification), (JustificationOptions::default(), &want), "only the paragraph the caret is in");
assert_eq!(serde_json::to_value(&second).unwrap()["justification"]["single_word"], "alignCenter");
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap();
assert_eq!(back.justification, want);
// Out of order: nothing changes, and the error names the setting.
let e = s.execute("text.setFormat", &json!({"wordSpacing": {"desired": 85}, "letterSpacing": {"min": 5}})).unwrap_err().to_string();
assert!(e.contains("letterSpacing"), "{e}");
assert_eq!(paras(&s).1.justification, want);
assert!(s.execute("text.setFormat", &json!({"glyphScaling": [100, 100]})).is_err());
assert!(s.execute("text.setFormat", &json!({"singleWordJustification": "spread"})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paras(&s).1.justification, JustificationOptions::default());
// A paragraph style carries them; an older file's paragraph reads the defaults.
s.execute("paraStyle.new", &json!({"name": "Airy", "attrs": {"justification": {"auto_leading": 200.0}}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Airy", "id": id})).unwrap();
assert_eq!(paras(&s).0.justification, JustificationOptions { auto_leading: 200.0, ..JustificationOptions::default() });
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap();
assert_eq!(old.justification, JustificationOptions::default());
}
/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts
/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing
/// carry them; params override; the values are journaled; imported text keeps the Roman
+13 -2
View File
@@ -160,6 +160,13 @@ fn edit_runs<S: Default>(s: &mut Session, label: &str, ids: &[NodeId], f: impl F
// ---------- Change Case ----------
/// Does `c` take a capital at the start of a word or sentence? Georgian has no word capitals:
/// Mtavruli is a separate all-caps style, so title and sentence case leave Mkhedruli (and turn
/// Mtavruli back into) lower case. Upper case still gives Mtavruli, as Unicode defines it.
fn capitalizes(c: char) -> bool {
!matches!(c, '\u{10D0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}')
}
#[derive(Default)]
struct CaseState {
/// Previous character (across runs).
@@ -187,7 +194,7 @@ fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
"upper" | "uppercase" => out.extend(c.to_uppercase()),
"lower" | "lowercase" => out.extend(c.to_lowercase()),
"title" => {
if st.prev.is_none_or(|p| p.is_whitespace() || matches!(p, '-' | '(' | '"' | '“' | '/')) {
if capitalizes(c) && st.prev.is_none_or(|p| p.is_whitespace() || matches!(p, '-' | '(' | '"' | '“' | '/')) {
out.extend(c.to_uppercase())
} else {
out.extend(c.to_lowercase())
@@ -195,7 +202,11 @@ fn change_case(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => {
if c.is_alphabetic() && st.sentence {
out.extend(c.to_uppercase());
if capitalizes(c) {
out.extend(c.to_uppercase());
} else {
out.extend(c.to_lowercase());
}
st.sentence = false;
} else {
out.extend(c.to_lowercase());
+64 -13
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); import also reads tab-separated files and files without the
//! `name` column. Agents reach every command through `run_command`, like all engine
//! then variable names); import also reads tab-separated files, files without the
//! `name` column, and header cells that define new variables (`#` for visibility, `@` for
//! image, no prefix for text). Agents reach every command through `run_command`, like all engine
//! commands.
use std::collections::{BTreeMap, BTreeSet};
@@ -29,8 +30,9 @@ const MAX_CSV_BYTES: usize = 1_048_576;
const MAX_CSV_ROWS: usize = 1_000;
const MAX_CSV_COLS: usize = 256;
/// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options) and
/// Make … Dynamic; binding, unbinding and the dataset commands still run.
/// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options),
/// Make … Dynamic and an import whose header would define a variable; binding, unbinding and the
/// other dataset commands still run.
fn variables_unlocked(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|st| st.doc.variables.locked) { Err("variables are locked".into()) } else { Ok(()) }
@@ -710,10 +712,52 @@ fn datasets_to_csv(vars: &Variables) -> String {
out
}
/// The variable that a datasets CSV header cell (trimmed, not empty) in 1-based `column` names,
/// and its kind. A cell that is the name of a variable in `kinds` or in `defined` (the variables
/// that earlier columns define) names that variable. Otherwise `#` before the name makes it a
/// visibility variable, `@` makes it an image variable, and a cell without a prefix names a text
/// variable. A prefixed name that is already a variable must be one of the prefix's kind; a new
/// name is added to `defined`. A `%` cell that names no variable (graph data files) is refused.
fn header_variable(
cell: &str,
column: usize,
kinds: &BTreeMap<String, VariableKind>,
defined: &mut Vec<Variable>,
cmd: &str,
) -> Result<(String, VariableKind)> {
let known = |name: &str, defined: &[Variable]| kinds.get(name).copied().or_else(|| defined.iter().find(|v| v.name == name).map(|v| v.kind));
if let Some(kind) = known(cell, defined) {
return Ok((cell.to_string(), kind));
}
let (name, kind) = if let Some(rest) = cell.strip_prefix('#') {
(rest.trim(), VariableKind::Visibility)
} else if let Some(rest) = cell.strip_prefix('@') {
(rest.trim(), VariableKind::Image)
} else if cell.starts_with('%') {
return Err(bad(cmd, format!("column {column}: `{cell}` is a `%` column of graph data files, which are not read")));
} else {
(cell, VariableKind::Text)
};
if name.is_empty() {
return Err(bad(cmd, format!("column {column}: `{cell}` names no variable")));
}
match known(name, defined) {
Some(k) if k == kind => Ok((name.to_string(), kind)),
Some(k) => Err(bad(cmd, format!("column {column}: `{cell}` names `{name}`, which is a {} variable", k.label()))),
None if name.chars().count() > MAX_NAME => Err(bad(cmd, format!("column {column}: the variable name is longer than {MAX_NAME} characters"))),
None => {
defined.push(Variable { name: name.to_string(), kind });
Ok((name.to_string(), kind))
}
}
}
/// 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.
/// names; the other headers name variables, as [`header_variable`] reads them, and the
/// variables the document doesn't define yet are defined, unbound. Without the `name` column,
/// every header names a variable 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)?;
@@ -730,13 +774,18 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
(kinds, existing)
};
let mut columns: Vec<(usize, String, VariableKind)> = Vec::new();
let mut defined: Vec<Variable> = Vec::new();
for (i, h) in header.iter().enumerate().skip(usize::from(name_column)) {
let name = h.trim();
if name.is_empty() {
let cell = h.trim();
if cell.is_empty() {
return Err(bad(C, format!("empty header in column {}", i + 1)));
}
let kind = kinds.get(name).copied().ok_or_else(|| bad(C, format!("no variable named `{name}`")))?;
columns.push((i, name.to_string(), kind));
let (name, kind) = header_variable(cell, i + 1, &kinds, &mut defined, C)?;
columns.push((i, name, kind));
}
// Lock Variables refuses New Variable, so the import is refused when a header would define one.
if !defined.is_empty() {
variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?;
}
if rows.len() == 1 {
return Err(bad(C, "CSV has a header but no data rows"));
@@ -777,7 +826,9 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
parsed.push(DataSet { name, values });
}
let names: Vec<String> = parsed.iter().map(|d| d.name.clone()).collect();
let new_variables: Vec<String> = defined.iter().map(|v| v.name.clone()).collect();
let (imported, updated) = s.edit("Import Data Sets", |d, _| {
d.variables.variables.extend(defined);
let mut imported = 0usize;
let mut updated = 0usize;
for row in parsed {
@@ -791,7 +842,7 @@ fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((imported, updated))
})?;
Ok(json!({"imported": imported, "updated": updated, "names": names}))
Ok(json!({"imported": imported, "updated": updated, "names": names, "defined": new_variables}))
}
/// Export every dataset as CSV (`name` then variables). With `path`, write the file; else return `{csv}`.
@@ -1003,7 +1054,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Import Data Sets…",
["Window", "Variables"],
None,
"{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}",
"{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…). A header that names no variable defines one, unbound: `#Name` defines a visibility variable, `@Name` defines an image variable, and a header without a prefix defines a text variable. A prefixed header whose name is a variable of another kind, and a `%` header that isn't a variable's exact name (graph data files), are refused, and so is a header that would define a variable while the variables are locked. Rows whose name already exists are replaced → {imported, updated, names, defined}",
has_doc,
dataset_import
),
+23 -1
View File
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::select::selectable_ids;
use super::*;
/// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in
@@ -153,7 +154,13 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone();
let w = s.menu.wand.clone();
let hits: Vec<NodeId> = paintable(&st.doc).into_iter().filter(|i| st.doc.node(*i).is_some_and(|n| w.matches(&reference, n))).collect();
// In isolation mode only the isolated group's contents can be picked, as for Select Same.
let inside = st.isolation.map(|iso| selectable_ids(&st.doc, Some(iso)));
let hits: Vec<NodeId> = paintable(&st.doc)
.into_iter()
.filter(|i| inside.as_ref().is_none_or(|set| set.contains(i)))
.filter(|i| st.doc.node(*i).is_some_and(|n| w.matches(&reference, n)))
.collect();
let ids: Vec<NodeId> = match str_param(p, "mode").unwrap_or("set") {
"add" => st.selection.objects.iter().copied().chain(hits.iter().copied().filter(|h| !st.selection.objects.contains(h))).collect(),
"subtract" => st.selection.objects.iter().copied().filter(|i| !hits.contains(i)).collect(),
@@ -222,4 +229,19 @@ mod tests {
s.execute("select.magicWand", &json!({"id": b})).unwrap();
assert_eq!(selected(&s), [b]);
}
#[test]
fn isolation_mode_limits_the_wand_to_the_isolated_group() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
let a = rect(&mut s, 0.0, "#ff0000", 1.0);
let b = rect(&mut s, 20.0, "#ff0000", 1.0);
let outside = rect(&mut s, 40.0, "#ff0000", 1.0);
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
s.execute("object.isolate", &json!({"id": g})).unwrap();
s.execute("select.magicWand", &json!({"id": a})).unwrap();
assert_eq!(selected(&s), [a, b]);
assert!(!selected(&s).contains(&outside));
}
}
+23 -9
View File
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Web",
[],
None,
"{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray (gif/png8, as the next five), colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), quality?: 60 (jpg: 0–100), progressive?: false (jpg), optimized?: true (jpg), embedProfile?: false (jpg: sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/<slice name>.<ext>), output?: images (default)|html (an HTML page placing the image or slices, <stem>.html)} → {path, files: [path…], bytes (total), width, height}; no path → {files: [{name, dataBase64}], bytes, width, height} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself",
"{path?, preset?: a webExport.presets name (its settings, then these keys), format?: gif (default)|jpg|png8|png24, reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray (gif/png8, as the next five), colors?: 128 (2–256), dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, webSnap?: 0 (0–100 %: colours that close to a web-safe one become it), lossy?: 0 (0–100, gif), colorTable?: {locked?: [\"#rrggbb\"…] (kept when reducing), transparent?: [\"#rrggbb\"…] (become transparent), webShift?: [\"#rrggbb\"…] (become web-safe), sort?: none|hue|luminance|popularity} (colours as the preview's colors[].source), transparency?: true (gif/png8/png24), matte?: white (default)|black|none|\"#rrggbb\" (what edges blend into; jpg and opaque png24: the background), interlaced?: false (gif/png8/png24), quality?: 60 (jpg: 0–100), progressive?: false (jpg), optimized?: true (jpg), embedProfile?: false (jpg: sRGB profile), width?: px | height?: px | percent?: 100 (the image size, proportional; width wins over height, height over percent), antiAlias?: none|art (default)|type, clipToArtboard?: true (the artboard; false: the art and the slices), artboard?: 0 (0-based), convertToSrgb?: true (PNG files say they are sRGB), metadata?: none|copyright (default)|contact|all (File Info written as PNG text, a GIF or JPEG comment), slices?: all (default)|selected|none (one image per slice: images/<slice name>.<ext>), output?: images (default)|html (an HTML page placing the image or slices, <stem>.html)} → {path, files: [path…], bytes (total), width, height, warnings: [] or [a note that an image couldn't be decoded and is missing from the image]}; no path → {files: [{name, dataBase64}], bytes, width, height, warnings} (names such as page.html, images/page_01.gif). A single image without HTML is written at path itself",
has_doc,
export
),
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save for Web Preview",
[],
None,
"{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, dataBase64?}",
"{…the settings of document.exportForWeb (preset?, format?…), slice?: n (1-based, from slice.list; default the whole image), kbps?: 56.6 (connection speed for the download time), image?: false (also the optimised file as dataBase64)} what Save writes for the whole image (or one slice) → {format, width, height, bytes (exactly the file's size), seconds (to download at kbps), kbps, colors?: [{color: \"#rrggbb\", source: \"#rrggbb\" (the colour the reduction made, as colorTable lists it), transparent, locked, webShifted, webSafe}] (gif/png8: the colour table in file order), mappedToTransparent?: [\"#rrggbb\"…], slices: images an export writes, warnings (as document.exportForWeb's), dataBase64?}",
has_doc,
preview
),
@@ -873,9 +873,11 @@ pub fn export_files(doc: &Document, selected: &[NodeId], s: &WebSettings, stem:
continue;
}
let base: String = doc.slice_name(c.area).chars().map(|ch| if ch.is_alphanumeric() || "-_.".contains(ch) { ch } else { '-' }).collect();
let base = unique_name(&base, |n| taken.contains(&n.to_lowercase()));
taken.insert(base.to_lowercase());
let name = format!("{IMAGES}/{base}.{ext}");
// A slice named after a Windows device (`aux`, `NUL`…) gets a `-` after the device name.
let file = |base: &str| super::fileio::safe_file_name(&format!("{base}.{ext}"));
let base = unique_name(&base, |n| taken.contains(&file(n).to_lowercase()));
taken.insert(file(&base).to_lowercase());
let name = format!("{IMAGES}/{}", file(&base));
let [x0, y0, x1, y1] = c.px;
images.push(WebFile { name: name.clone(), bytes: optimize(doc, &crop(&r, c.px), x1 - x0, y1 - y0, s)?.bytes });
placed.push((Some(c), Some(name)));
@@ -967,14 +969,17 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").filter(|p| !p.trim().is_empty());
let stem = match path {
Some(p) => super::fileio::file_stem(p),
None => super::fileio::file_stem(&st.doc.title),
// Without a path the files are named after the title, following Windows' file-naming rules.
None => super::fileio::safe_file_name(&super::fileio::file_stem(&st.doc.title)),
};
let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem };
let failed = vectorcraft_render::decode_failures();
let (files, (width, height)) = export_files(&st.doc, &selected, &settings, &stem).map_err(|e| bad(C, e))?;
let warnings = undecoded(failed);
let total: usize = files.iter().map(|f| f.bytes.len()).sum();
let Some(path) = path else {
let rows: Vec<Value> = files.iter().map(|f| json!({ "name": f.name, "dataBase64": vectorcraft_format::base64_encode(&f.bytes) })).collect();
return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height }));
return Ok(json!({ "files": rows, "bytes": total, "width": width, "height": height, "warnings": warnings }));
};
// Siblings keep the path's own separators.
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
@@ -988,7 +993,14 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
super::fileio::write_file(&target, &f.bytes)?;
written.push(target);
}
Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height }))
Ok(json!({ "path": written.first(), "files": written, "bytes": total, "width": width, "height": height, "warnings": warnings }))
}
/// The warnings of a rendering that started when this thread's decode failure count was `failed`:
/// the note the raster exports add (`fileio::encode::encode_all_of`) when an image has failed to
/// decode since then and so is missing from the image.
fn undecoded(failed: u64) -> Vec<&'static str> {
super::rasterfx::undecoded_warning(failed).into_iter().collect()
}
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1000,7 +1012,9 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
}
let st = s.doc()?;
let doc = &st.doc;
let failed = vectorcraft_render::decode_failures();
let r = render(doc, &settings).map_err(|e| bad(P, e))?;
let warnings = undecoded(failed);
let areas = doc.slice_layout();
let selected = super::slices::selected_slices(doc, &st.selection);
let cells = cells(&areas, &selected, &settings, &r).map_err(|e| bad(P, e))?;
@@ -1016,7 +1030,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
.map_err(|e| bad(P, e))?;
let mut out = json!({
"format": settings.format.id(), "width": o.width, "height": o.height, "bytes": o.bytes.len(),
"seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images,
"seconds": download_seconds(o.bytes.len(), kbps), "kbps": kbps, "slices": images, "warnings": warnings,
});
if settings.format.palette() {
out["colors"] = o
+61 -3
View File
@@ -91,6 +91,27 @@ pub struct HistoryEntry {
next_bytes: usize,
/// Which document the figures were taken against (its address): the next state.
next: usize,
/// The template and name `label` was made from ([`Session::edit_named`]).
named: Option<NamedStep>,
}
/// A step name made by filling the `{name}` placeholder of a template with a name
/// ([`Session::edit_named`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NamedStep {
/// The English template, with one `{name}` placeholder (`Apply {name}`).
pub template: &'static str,
/// The name in the placeholder, as the document holds it (a style's name).
pub name: String,
/// The name is one VectorCraft gives (`[Normal Character Style]`), not one the user typed.
pub built_in: bool,
}
impl NamedStep {
/// The step name: the template with the name in its placeholder.
pub fn label(&self) -> String {
self.template.replace("{name}", &self.name)
}
}
impl HistoryEntry {
@@ -98,7 +119,14 @@ impl HistoryEntry {
pub(crate) fn kept(label: String, doc: Arc<Document>, selection: Arc<Selection>, next: &Document) -> Self {
doc.drop_lookup_cache();
let (bytes, next_bytes) = doc.retained_bytes_each(next);
HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize }
HistoryEntry { label, doc, selection, bytes, next_bytes, next: std::ptr::from_ref(next) as usize, named: None }
}
/// The template and name the step's label was made from, while the label is still the one
/// they make: `None` for a step named any other way, and for one whose label was changed
/// since it was recorded.
pub fn named(&self) -> Option<&NamedStep> {
self.named.as_ref().filter(|n| n.label() == self.label)
}
/// The step that undoing (redoing) the step `costs` came from makes: it keeps `doc`, the
@@ -112,7 +140,7 @@ impl HistoryEntry {
return Self::kept(label, doc, selection, now);
}
doc.drop_lookup_cache();
HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize }
HistoryEntry { label, doc, selection, bytes: costs.next_bytes, next_bytes: costs.bytes, next: Arc::as_ptr(now) as usize, named: None }
}
/// The figures [`HistoryEntry::turned`] reuses.
@@ -918,7 +946,8 @@ pub struct Prefs {
/// Rasterizer worker threads; -1 = automatic.
pub render_threads: i32,
/// Linux: `automatic` opens the window on the session's display server; `x11` opens it through
/// XWayland on a Wayland session, where dropped files arrive (#1151). Read when the app starts.
/// XWayland on a Wayland session, where dropped files arrive (#1151); `x11WithPen` opens it
/// through XWayland only when a pen is attached (#2028). Read when the app starts.
pub linux_display_server: String,
// File Handling
pub background_save: bool,
@@ -1738,6 +1767,33 @@ impl Session {
}
}
/// [`Session::edit`] with the step named `template`, its `{name}` placeholder filled with `name`
/// (a style's name). The undo step it records keeps the template and the name
/// ([`HistoryEntry::named`]), and a frontend shows the template's translation with the name in
/// it. An edit that records no step of its own (inside an interaction, or a later edit of an
/// undo group) keeps neither, and neither does a step an undo group names.
pub fn edit_named<T>(
&mut self,
template: &'static str,
name: &str,
built_in: bool,
f: impl FnOnce(&mut Document, &mut Selection) -> Result<T>,
) -> Result<T> {
let named = NamedStep { template, name: name.to_string(), built_in };
let step = named.label();
// The step this edit records keeps the document as it is now.
let now = Arc::as_ptr(&self.doc()?.doc);
let v = self.edit(&step, f)?;
if let Ok(st) = self.doc_mut()
&& let Some(e) = st.history.undo.last_mut()
&& Arc::as_ptr(&e.doc) == now
&& e.label == step
{
e.named = Some(named);
}
Ok(v)
}
/// Change only the selection (not an undo step).
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
self.active_appearance_item = None;
@@ -2101,6 +2157,8 @@ mod tests_legacy_ai;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_link_paths;
#[cfg(test)]
mod tests_linked_stops;
#[cfg(test)]
mod tests_linkfiles;
+11 -6
View File
@@ -30,15 +30,19 @@ pub type Stamp = (u64, u128);
/// object ids, stamp)]).
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
/// The stamp of the linked file stored as `path` in the document saved at `doc_path`; `None` when
/// there is no file, its path isn't followed ([`crate::cmd::fileio::document_path`]) or
/// automation may not read it.
#[cfg(not(target_arch = "wasm32"))]
fn stamp(path: &str) -> Option<Stamp> {
let meta = std::fs::metadata(path).ok()?;
fn stamp(path: &str, doc_path: Option<&str>) -> Option<Stamp> {
let path = crate::cmd::fileio::document_path(path, doc_path).ok()?;
let meta = crate::cmd::fileio::file_metadata(&path)?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((meta.len(), modified.as_nanos()))
}
#[cfg(target_arch = "wasm32")]
fn stamp(_: &str) -> Option<Stamp> {
fn stamp(_: &str, _: Option<&str>) -> Option<Stamp> {
None
}
@@ -52,7 +56,7 @@ pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, st.path.as_deref()))).collect();
s.apply_link_stamps(uid, now, true)
}
@@ -64,14 +68,15 @@ impl Session {
return;
}
let Some(st) = self.active() else { return };
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
let (uid, files, doc_path) = (st.uid, crate::cmd::links::linked_groups(&st.doc), st.path.clone());
if files.is_empty() {
return;
}
let slot: LinkScan = Arc::default();
let out = slot.clone();
let scope = crate::file_access::ScopeHandle::capture();
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
let stamps = scope.run(|| files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, doc_path.as_deref()))).collect());
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
});
if spawned.is_ok() {
+33 -9
View File
@@ -133,22 +133,46 @@ impl Session {
}
/// 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.
/// (a rename). A rename writes the new file first and removes the old one only when that write
/// succeeded: after a failed write the store still holds the preset under its old name. When
/// the two names differ only in case, the old file is written and then renamed to the new name;
/// when that rename fails, the old file gets its content back. 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) => {
let bytes = match serde_json::to_vec_pretty(&PresetFile::new(vec![clean])) {
Ok(bytes) => bytes,
Err(e) => {
log::warn!("can't save the PDF preset `{}`: {e}", preset.name);
return;
}
};
match old_name.map(|o| format!("{}.{PRESET_FORMAT}", file_stem(o))).filter(|old| *old != file) {
// Names equal in any case get file names that differ only in case. A file system that
// ignores case holds them as one file, which removing the old name would delete.
Some(old) if old.to_lowercase() == file.to_lowercase() => match read_capped(&dir.join(&old), MAX_PRESET_FILE) {
Ok(before) => {
if put(dir, &old, &bytes)
&& let Err(e) = std::fs::rename(dir.join(&old), dir.join(&file))
{
log::warn!("can't rename {} to {file}: {e}", dir.join(&old).display());
put(dir, &old, &before);
}
}
Err(_) => {
put(dir, &file, &bytes);
}
},
Some(old) => {
if put(dir, &file, &bytes) {
take(dir, &old);
}
}
None => {
put(dir, &file, &bytes);
}
Err(e) => log::warn!("can't save the PDF preset `{}`: {e}", preset.name),
}
}
+94
View File
@@ -28,6 +28,61 @@ fn create_and_undo_redo() {
assert!(s.execute("edit.redo", &json!({})).is_err());
}
/// Clear History removes the steps to undo and the steps to redo. The document and the selection
/// stay as they are, Undo and Redo are then disabled, and clearing is not a step itself.
#[test]
fn clear_history_removes_every_step_and_keeps_the_document() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
let b = rect(&mut s, 200.0, 10.0, 100.0, 50.0);
rect(&mut s, 400.0, 10.0, 100.0, 50.0);
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.history.redo.len()), (2, 1));
let (doc, selection, dirty) = (st.doc.clone(), st.selection.clone(), st.is_dirty());
assert_eq!(s.execute("edit.clearHistory", &json!({})).unwrap(), json!({"cleared": 3}));
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.history.redo.len()), (0, 0));
assert!(Arc::ptr_eq(&st.doc, &doc), "the document is the one from before clearing");
assert_eq!((&st.selection, st.is_dirty()), (&selection, dirty));
assert!(st.doc.node(a).is_some() && st.doc.node(b).is_some());
for cmd in ["edit.undo", "edit.redo", "edit.clearHistory"] {
assert!(matches!(s.execute(cmd, &json!({})), Err(EngineError::Disabled(..))), "{cmd}");
}
// A new edit is a step again: undoing it goes back to the document the history was cleared at.
rect(&mut s, 600.0, 10.0, 100.0, 50.0);
s.execute("edit.undo", &json!({})).unwrap();
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &doc));
assert!(s.execute("edit.undo", &json!({})).is_err());
}
/// Clear History is disabled without a document and in a document with no step to undo or redo.
#[test]
fn clear_history_needs_a_step_to_clear() {
let mut s = Session::new();
assert!(matches!(s.execute("edit.clearHistory", &json!({})), Err(EngineError::Disabled(..))), "no document");
s.execute("file.new", &json!({})).unwrap();
match s.execute("edit.clearHistory", &json!({})) {
Err(EngineError::Disabled(_, why)) => assert_eq!(why, "no history to clear"),
other => panic!("a new document: {other:?}"),
}
}
/// A drag still in progress when the history is cleared ends, and its change stays in the document
/// without a step to undo it.
#[test]
fn clear_history_keeps_a_drag_in_progress_in_the_document() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
assert_eq!(s.execute("edit.clearHistory", &json!({})).unwrap(), json!({"cleared": 2}));
assert!(!s.in_interaction());
let st = s.doc().unwrap();
assert_eq!(st.doc.node(a).unwrap().geometric_bounds().unwrap().x0, 40.0);
assert!(st.history.undo.is_empty() && st.history.redo.is_empty());
}
#[test]
fn unknown_and_disabled() {
let mut s = Session::new();
@@ -1708,6 +1763,45 @@ fn direct_selection_snaps_anchors_to_the_grid() {
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(201.0, 101.0));
}
/// #2054: with Snap to Grid on, a bounding-box handle dragged with the Selection tool or the Free
/// Transform tool lands the corner it moves on the grid, even with Snap to Pixel left on too; the
/// opposite corner stays put.
#[test]
fn bounding_box_handles_snap_to_the_grid() {
for tool in ["selection", "freeTransform"] {
let mut s = session();
let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0);
let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1));
let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;
let v = ViewInfo { snap_to_grid: true, snap_to_pixel: true, ..ViewInfo::default() };
s.select_tool(tool, v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 201.0, 201.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 230.0, 214.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 230.0, 214.0), v).unwrap();
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert!(on_grid(b.x1) && on_grid(b.y1), "{tool}: {b:?}, grid step {step}");
assert!((b.x0 - 101.0).abs() < 1e-9 && (b.y0 - 101.0).abs() < 1e-9, "{tool}: {b:?}");
}
}
/// A Shift-constrained move with Snap to Grid on stays level: the square's center lands on a
/// gridline across, and its top stays where it was, off the grid.
#[test]
fn a_shift_move_snapped_to_the_grid_stays_on_its_line() {
let mut s = session();
let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0);
let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1));
let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;
let v = ViewInfo { snap_to_grid: true, ..ViewInfo::default() };
let shift = vectorcraft_tools::Mods { shift: true, ..Default::default() };
s.select_tool("selection", v).unwrap();
for (kind, x, y) in [(PointerKind::Down, 151.0, 151.0), (PointerKind::Drag, 180.0, 153.0), (PointerKind::Up, 180.0, 153.0)] {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(shift), v).unwrap();
}
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
assert!(on_grid(b.center().x) && (b.y0 - 101.0).abs() < 1e-9, "{b:?}, grid step {step}");
}
/// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all
/// its anchors, so toggling one deselects it alone and adding one keeps them all.
#[test]
+49
View File
@@ -66,6 +66,55 @@ fn a_spine_point_added_and_moved_bends_the_blend() {
assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 5.0)) < 1e-6);
}
/// #2544: an Anchor Point drag on a blend spine must create two symmetric
/// handles, not bend the adjacent segment and leave one handle behind.
#[test]
fn spine_anchor_conversion_preserves_keys_and_undo() {
let mut s = session();
let (a, b, g) = blend(&mut s);
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
let keys_before = spec(&s, g).key_anchors;
let point_before = spec(&s, g).spine.unwrap().subpaths[0].anchors[1].p;
assert!(point_before.distance(Point::new(55.0, 5.0)) < 1e-9);
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "smooth", "x": 55, "y": 35})).unwrap();
let sp = spec(&s, g);
assert_eq!(sp.key_anchors, keys_before);
let middle = sp.spine.as_ref().unwrap().subpaths[0].anchors[1];
assert_eq!(middle.kind, vectorcraft_geom::AnchorKind::Smooth);
assert_eq!(middle.p, point_before);
assert_eq!(middle.h_in, point_before - (Point::new(55.0, 35.0) - point_before));
assert_eq!(middle.h_out, Point::new(55.0, 35.0));
assert_eq!(center(&node(&s, a)), Point::new(5.0, 5.0));
assert_eq!(center(&node(&s, b)), Point::new(105.0, 5.0));
s.execute("edit.undo", &json!({})).unwrap();
let before = spec(&s, g);
assert_eq!(before.spine.unwrap().subpaths[0].anchors[1].h_out, point_before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1], middle);
s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "corner"})).unwrap();
let corner = spec(&s, g).spine.unwrap().subpaths[0].anchors[1];
assert!(!corner.has_in() && !corner.has_out());
assert_eq!(corner.kind, vectorcraft_geom::AnchorKind::Corner);
}
#[test]
fn spine_conversion_rejects_invalid_arguments_without_mutation() {
let mut s = session();
let (_, _, g) = blend(&mut s);
let before = spec(&s, g);
for params in [
json!({"id": g.0, "anchor": 5, "to": "smooth", "x": 20, "y": 20}),
json!({"id": g.0, "anchor": 0, "to": "unknown"}),
json!({"id": g.0, "anchor": 0, "to": "smooth", "x": 10}),
json!({"id": g.0, "anchor": 0, "to": "smooth", "x": f64::MAX, "y": 1}),
] {
assert!(s.execute("object.blend.spine.convertAnchor", &params).is_err(), "{params}");
assert_eq!(spec(&s, g), before);
}
}
#[test]
fn moving_a_key_point_moves_the_key_and_the_other_way_round() {
let mut s = session();
+57
View File
@@ -140,6 +140,32 @@ fn brush_options_rename_duplicate_and_delete_update_strokes() {
assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err());
}
/// New Brush, Duplicate Brush and Brush Options trim the name they are given, as the Brush Options
/// dialog does; the trimmed name is the one compared with the brushes there are, and a blank name
/// gives the name the command gives when there is none.
#[test]
fn new_duplicate_and_renamed_brushes_trim_their_names() {
let mut s = session();
let new = |s: &mut Session, name: &str| s.execute("brush.new", &json!({"type": "calligraphic", "name": name})).unwrap()["name"].clone();
assert_eq!(new(&mut s, " Nib \t"), "Nib");
assert_eq!(new(&mut s, " Nib"), "Nib 2");
assert_eq!(new(&mut s, " \t "), "New Calligraphic Brush");
assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " Quill\t"})).unwrap()["name"], "Quill");
assert_eq!(s.execute("brush.duplicate", &json!({"name": "Nib", "newName": " "})).unwrap()["name"], "Nib copy");
assert_eq!(s.execute("brush.options", &json!({"name": "Nib 2", "newName": " Pen "})).unwrap()["name"], "Pen");
assert_eq!(s.execute("brush.options", &json!({"name": "Pen", "newName": " "})).unwrap()["name"], "Pen");
let e = s.execute("brush.options", &json!({"name": "Pen", "newName": " Quill"})).unwrap_err().to_string();
assert!(e.contains("a brush named `Quill` exists"), "{e}");
// Duplicate Brush names the copy of a brush whose saved name has white space around it, as a
// saved file can hold, from the trimmed name (" Old " gives "Old copy").
let mut lib = vectorcraft_brush::library(&s.doc().unwrap().doc);
lib.iter_mut().filter(|b| b.name == "Pen").for_each(|b| b.name = " Old ".into());
vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib);
assert_eq!(s.execute("brush.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy");
let names: Vec<String> = vectorcraft_brush::library(&s.doc().unwrap().doc).into_iter().map(|b| b.name).collect();
assert_eq!(names[names.len() - 6..], ["Nib", " Old ", "New Calligraphic Brush", "Quill", "Nib copy", "Old copy"]);
}
/// The Brushes panel's multi-selection: `names` deletes or duplicates several brushes in one undo
/// step, and an unknown name changes nothing.
#[test]
@@ -541,6 +567,23 @@ fn delete_duplicate_and_replace_symbols() {
assert!(s.execute("symbol.delete", &json!({"name": "Small"})).is_err());
}
/// New Symbol and Duplicate Symbol trim the name they are given, as Symbol Options trims
/// `newName`; the trimmed name is the one compared with the symbols there are.
#[test]
fn new_and_duplicate_symbols_trim_their_names_as_a_rename_does() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert_eq!(s.execute("symbol.new", &json!({"name": " Box \t"})).unwrap()["name"], "Box");
rect(&mut s, 20.0, 0.0, 10.0, 10.0);
assert_eq!(s.execute("symbol.new", &json!({"name": " Box"})).unwrap()["name"], "Box 2");
rect(&mut s, 40.0, 0.0, 10.0, 10.0);
assert_eq!(s.execute("symbol.new", &json!({"name": " "})).unwrap()["name"], "New Symbol");
assert_eq!(s.execute("symbol.duplicate", &json!({"name": "Box", "newName": " Logo\t"})).unwrap()["name"], "Logo");
assert_eq!(s.execute("symbol.duplicate", &json!({"name": "Box", "newName": " "})).unwrap()["name"], "Box copy");
let names: Vec<String> = s.doc().unwrap().doc.symbols.iter().map(|x| x.name.clone()).collect();
assert_eq!(names, ["Box", "Box 2", "New Symbol", "Logo", "Box copy"]);
}
#[test]
fn symbol_export_type_defaults_to_movie_clip_and_round_trips() {
let mut s = session();
@@ -612,6 +655,20 @@ fn symbolism_adjustments() {
assert!(s.execute("symbol.adjust", &json!({"tool": "nope", "points": at})).is_err());
}
/// In a CMYK document the Symbol Stainer's RGB `color` separates through the color settings, as
/// `paint.setFill` stores it.
#[test]
fn symbol_stainer_rgb_color_in_a_cmyk_document_separates_through_the_color_settings() {
use vectorcraft_color::{Color, Paint, cms};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap();
let (inst, _, b) = make_symbol(&mut s);
let c = b.center();
s.execute("symbol.adjust", &json!({"tool": "stain", "points": [[c.x, c.y]], "color": "#ff0000", "intensity": 10})).unwrap();
let red = cms::active().convert(&Color::rgb(1.0, 0.0, 0.0), cms::Model::Cmyk, cms::active().settings().intent);
assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red)));
}
#[test]
fn symbol_sprayer_tool_gesture_is_one_undo_step() {
let mut s = session();
+32
View File
@@ -97,6 +97,38 @@ fn exported_pdf_pastes_back_as_vectors() {
assert!(t.execute("clipboard.importPdf", &json!({})).is_err());
}
#[test]
fn the_png_and_pdf_say_when_an_image_or_a_font_is_missing() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let warnings = |r: &Value| -> Vec<String> { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() };
let mut s = session();
copy_red_square(&mut s);
assert_eq!(run(&mut s, "clipboard.exportPng", json!({}))["warnings"], json!([]));
assert_eq!(run(&mut s, "clipboard.exportPdf", json!({}))["warnings"], json!([]));
// Type in a family that isn't installed: the PNG and the PDF each name it.
let text = run(&mut s, "text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [text]}));
run(&mut s, "edit.copy", json!({}));
let png = warnings(&run(&mut s, "clipboard.exportPng", json!({})));
assert!(png.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{png:?}");
let pdf = warnings(&run(&mut s, "clipboard.exportPdf", json!({})));
assert!(pdf.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{pdf:?}");
// Copy's formats carry the notes of both, each once.
let (flavours, notes) = s.clipboard.flavours_with_warnings(false, true);
assert_eq!(flavours.iter().map(|f| f.mime).collect::<Vec<_>>(), [TEXT, PDF, PNG]);
for w in png.iter().chain(&pdf) {
assert_eq!(notes.iter().filter(|n| *n == w).count(), 1, "{w} in {notes:?}");
}
// An image the renderer can't decode: the PNG leaves it out and says so.
let image = crate::tests_menucmds::place_undecodable_png(&mut s);
run(&mut s, "select.set", json!({"ids": [image.0]}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(warnings(&run(&mut s, "clipboard.exportPng", json!({}))), [NOTE]);
let (flavours, notes) = s.clipboard.flavours_with_warnings(false, false);
assert_eq!(flavours.iter().map(|f| f.mime).collect::<Vec<_>>(), [PNG]);
assert_eq!(notes, [NOTE]);
}
#[test]
fn import_image_then_paste_gives_an_image() {
let mut s = session();
+5
View File
@@ -103,4 +103,9 @@ fn add_to_swatches_makes_a_colour_group_in_one_step() {
// Again: a second group with a free name.
assert_eq!(run(&mut s, "colorTheme.addToSwatches", json!({"name": "Tide"}))["name"], "Tide 2");
assert!(s.execute("colorTheme.addToSwatches", &json!({"name": "Nope"})).is_err());
// In a CMYK document the theme's colors keep their model.
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"}));
run(&mut s, "colorTheme.addToSwatches", json!({"name": "Tide"}));
let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Tide").expect("the group");
assert_eq!(g.swatches.iter().filter_map(|w| w.paint.color()).collect::<Vec<_>>(), s.prefs.color_themes[0].colors);
}
+22
View File
@@ -115,3 +115,25 @@ fn export_to_a_path_puts_the_pictures_next_to_the_sheet() {
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn export_says_once_when_a_rasterized_image_could_not_be_decoded() {
const NOTE: &str = "an image couldn't be decoded and is missing from the picture";
let notes = |r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str() == Some(NOTE)).count();
let mut s = session();
run(&mut s, "shape.star", json!({"cx": 100, "cy": 100, "radius1": 40, "radius2": 20}));
assert_eq!(notes(&run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}))), 0);
// Two images the renderer can't decode, each rasterized to a picture of its own.
crate::tests_menucmds::place_undecodable_png(&mut s);
crate::tests_menucmds::place_undecodable_png(&mut s);
let r = run(&mut s, "css.export", json!({"scope": "all", "rasterize": true}));
assert_eq!(r["images"].as_array().unwrap().len(), 3, "{r}");
assert_eq!(notes(&r), 1, "{}", r["warnings"]);
let dir = std::env::temp_dir().join(format!("vc-css-undecoded-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let r = run(&mut s, "css.export", json!({"path": dir.join("site.css").to_string_lossy(), "scope": "all", "rasterize": true}));
assert_eq!(r["images"].as_array().unwrap().len(), 3, "the pictures are still written");
assert_eq!(notes(&r), 1, "{}", r["warnings"]);
std::fs::remove_dir_all(&dir).ok();
}
+75
View File
@@ -199,6 +199,81 @@ fn character_styles_follow_the_setup_sizes() {
assert_eq!(def.attrs["small_caps"], json!(80.0));
}
/// A file whose type, symbols, graph label styles, character styles or pattern definition saved in
/// pattern editing mode hold superscript, subscript or small caps proportions outside the ranges
/// `document.setup` takes opens with each at the nearer end of its range; the other character
/// attributes open as saved.
#[test]
fn a_file_with_script_proportions_out_of_range_opens_with_them_in_range() {
let mut s = session();
let id = create_text(&mut s, "E=mc2");
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 4, "end": 5, "position": "superscript"})).unwrap();
s.execute("text.setFormat", &json!({"ids": [id.0], "smallCaps": true})).unwrap();
let water = create_text(&mut s, "H2O");
s.execute("text.setRangeStyle", &json!({"id": water.0, "start": 1, "end": 2, "position": "subscript"})).unwrap();
let csv = ",2024\nQ1,3\nQ2,5";
let graph = s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 150, "width": 200, "height": 100, "csv": csv})).unwrap();
let graph = NodeId(graph["id"].as_u64().unwrap());
let exponent = create_text(&mut s, "x2");
s.execute("text.setRangeStyle", &json!({"id": exponent.0, "start": 1, "end": 2, "position": "superscript"})).unwrap();
s.execute("select.set", &json!({"ids": [exponent.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Exponent", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
// Saved in pattern editing mode: Cancel puts back the definition from before the edit.
s.execute("object.pattern.edit", &json!({"name": "Exponent"})).unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.runs[0].style.small_caps = Some(-5.0);
let two = &mut t.runs[1].style;
two.position = CharPosition::Superscript(ScriptMetrics { size: 0.0, position: f64::MAX });
two.small_caps = Some(f64::MAX);
}
if let Some(NodeKind::Text(t)) = d.node_mut(water).map(|n| &mut n.kind) {
t.runs[1].style.position = CharPosition::Subscript(ScriptMetrics { size: f64::MAX, position: -1000.0 });
t.runs[1].style.tracking = -5000.0;
}
let mut art = d.node(id).unwrap().clone();
art.id = NodeId(9000);
d.symbols.push(vectorcraft_doc::Symbol { name: "Formula".into(), art: Arc::new(art) });
if let Some(g) = d.node_mut(graph).and_then(|n| n.extras_if_any()).and_then(|e| e.graph.as_deref_mut()) {
let position = CharPosition::Subscript(ScriptMetrics { size: 0.0, position: 1000.0 });
g.label_styles.legend = Some(vectorcraft_doc::CharStyle { position, small_caps: Some(0.0), ..vectorcraft_doc::CharStyle::default() });
}
let original = d.pattern_edit.as_mut().and_then(|pe| pe.original.as_mut()).and_then(|p| p.art.first_mut());
if let Some(NodeKind::Text(t)) = original.map(|a| &mut Arc::make_mut(a).kind) {
t.runs[1].style.position = CharPosition::Superscript(ScriptMetrics { size: 0.0, position: f64::MAX });
}
let attrs = json!({"position": {"kind": "superscript", "size": 1000.0, "position": -1000.0}, "small_caps": 0.0, "tracking": -5000.0});
d.char_styles.push(TextStyleDef { name: "Note".into(), attrs: attrs.as_object().unwrap().clone() });
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save_file(&d));
s.execute("document.open", &json!({"name": "scripts.vectorcraft", "dataBase64": data})).unwrap();
let raised = CharPosition::Superscript(ScriptMetrics { size: 1.0, position: 500.0 });
let t = text(&s, id);
assert_eq!((t.runs[0].style.small_caps, t.runs[1].style.small_caps), (Some(1.0), Some(200.0)));
assert_eq!(t.runs[1].style.position, raised);
let t = text(&s, water);
assert_eq!(t.runs[1].style.position, CharPosition::Subscript(ScriptMetrics { size: 500.0, position: -500.0 }));
assert_eq!(t.runs[1].style.tracking, -5000.0, "other attributes open as saved");
let doc = &s.doc().unwrap().doc;
match &doc.symbols.iter().find(|s| s.name == "Formula").unwrap().art.kind {
NodeKind::Text(t) => assert_eq!(t.runs[1].style.position, raised),
_ => panic!("the symbol's art is not text"),
}
let legend = doc.node(graph).unwrap().extras().graph.as_ref().unwrap().label_styles.legend.clone().unwrap();
assert_eq!((legend.position, legend.small_caps), (CharPosition::Subscript(ScriptMetrics { size: 1.0, position: 500.0 }), Some(1.0)));
let def = doc.char_styles.iter().find(|d| d.name == "Note").unwrap();
assert_eq!(def.attrs["position"], json!({"kind": "superscript", "size": 500.0, "position": -500.0}));
assert_eq!((&def.attrs["small_caps"], &def.attrs["tracking"]), (&json!(1.0), &json!(-5000.0)));
s.execute("object.pattern.cancel", &json!({})).unwrap();
match &s.doc().unwrap().doc.pattern("Exponent").unwrap().art[0].kind {
NodeKind::Text(t) => assert_eq!(t.runs[1].style.position, raised),
_ => panic!("the pattern's art is not text"),
}
// With the file's proportions kept as saved, the move failed with "result would exceed the canvas".
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
}
#[test]
fn setup_survives_save_and_load() {
let mut s = session();
+27
View File
@@ -849,6 +849,33 @@ fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() {
}
}
#[test]
fn a_selected_curve_whose_handles_are_near_1e308_is_refused() {
// The canvas check measures a selected curve whose handles are near ±1e308 by its control
// points, so `path.create` refuses such a curve, given as anchors or as path data: the document
// stays as it was, with no undo step.
let mut s = session();
curve(&mut s);
let h = 1e308;
for p in [
json!({"anchors": [{"x": 100, "y": 100, "out": [150, h]}, {"x": 300, "y": 100, "in": [250, h]}]}),
json!({"anchors": [{"x": 100, "y": 100, "out": [150, -h]}, {"x": 300, "y": 100, "in": [250, h]}]}),
json!({"anchors": [{"x": 100, "y": 100, "out": [1.7e308, 100]}, {"x": 300, "y": 100}]}),
json!({"d": "M100 100 C150 1e308 250 1e308 300 100"}),
] {
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let e = s.execute("path.create", &p).unwrap_err().to_string();
assert!(e.contains("exceed the canvas"), "{p}: {e}");
assert!(s.doc().unwrap().doc == before, "{p} changed the document");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{p} left an undo step");
}
// A curve whose handles stay on the canvas is kept.
let p = json!({"anchors": [{"x": 100, "y": 100, "out": [150, 3e6]}, {"x": 300, "y": 100, "in": [250, 3e6]}]});
let id = NodeId(s.execute("path.create", &p).unwrap()["id"].as_u64().unwrap());
assert!(path(&s, id).bounds().is_some_and(|b| b.y1 > 2e6));
}
/// A curve from (100, 100) to (300, 100) whose first anchor has an out handle at (150, 100).
fn curve(s: &mut Session) -> NodeId {
let anchors = json!([{"x": 100, "y": 100, "out": [150, 100]}, {"x": 300, "y": 100, "in": [250, 100]}]);
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff. Show more