Transform Each, Rasterize, Create Object Mosaic, Create Trim Marks and
Rearrange All Artboards read their optional numbers with f64_or, which
falls back to a default for a value that is not a JSON number.
object.transformEach {"scaleH": "50"} left the selected objects at
their size and reported success. Rasterize did the same with ppi,
Create Object Mosaic did the same with width, height, spacingX and
spacingY, Create Trim Marks did the same with offset, length, bleed and
weight, and Rearrange All Artboards did the same with spacing. Crop
Image cropped to the artboard when rect was a string or another value
that is not an array.
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. Transform Each checks scaleH, scaleV, moveH,
moveV and rotate. Crop Image refuses a rect that is not an array of
four numbers with "rect must be [x, y, width, height]", the message it
already gave for an array of the wrong length; a null rect crops to the
artboard, as a missing one does.
Docs: docs/mcp.md, docs/target-app-parity.md.
document.pdfInfo refused PostScript (an EPS, a .ps file, or a .ai in
PostScript form), mostly with "this is a PostScript file ..., not a
PDF", although document.open and file.place read the same file
through the EPS reader as one page.
It now answers {pages: 1, needsPassword: false, pageInfo: [{width,
height, rotation: 0, boxes: {}}]}. The page is the file's bounding box
(%%HiResBoundingBox when it has one, a US Letter page without one),
read from its comments without running its program. thumbnail: 1 is a
PNG of what that page prints, read by the EPS reader without any
editing data the file carries. A program whose header is only %! is
read as PostScript, as clipboard.importEps reads it.
Docs: docs/mcp.md, docs/target-app-parity.md.
The PostScript interpreter that reads EPS and PostScript .ai files
accepts a FlateDecode or LZWDecode filter with Predictor 2 at
BitsPerComponent 1, 2, 4, 8 and 16, but undid the horizontal
differencing only at 8 bits. At the other depths the filter gave back
the stored differences, and the image drew them instead of its
samples.
The predictor is now undone at every depth it accepts, as ISO 32000-1
§7.4.4.4 describes: each component adds the same component of the
pixel to its left, within its bit width, and each row starts again.
16-bit components add as big-endian 16-bit numbers. The padding bits
at the end of a row stay as they are, and 8-bit data takes the same
path as before. The PNG predictors are unchanged.
Docs: docs/target-app-parity.md.
A compound path holds its members, so select.color passed over it and
looked at the members instead, whose paint inspect doesn't report: a
black compound path (art with holes, as PDFs bring in) never matched
"#000000". It now matches by its own fill and stroke, and its members
never do. Type without characters reads as the default character fill,
as inspect reports it (CodeRabbit on #2939).
Scripts had to know an object id before they could select art by colour
(select.same.* and select.magicWand start from one). select.color
{fill?, stroke?} takes colours as paint.setFill does and selects the
objects whose fill and stroke read as them in #rrggbb, as
document.inspect reports colours, with Select > Same's selection rules.
The label is translated in each of the 23 catalogs, which
complete_languages_translate_every_menu_string requires.
While a Type tool edited text, a click on blank canvas ended the
editing and also placed new point type at the click. Area Type and
Type on a Path did the same with a click on a path.
Such a click now only ends the editing. The typing session is
committed, empty point type that a click placed is discarded as before,
and the Type tool in use stays active; the next click makes new type.
The same applies when the editing began with a double-click from a
selection tool. A click on other type still edits it at once, and a
drag that draws an area-type frame still makes the frame.
In number fields, Up/Down and a wheel notch added a whole step and kept
the fraction, so 5.1 mm stepped up to 6.1 mm. A plain step now goes to
the next whole unit in its direction (5.1 goes up to 6 and down to 5; 5
goes up to 6 and down to 4), and a Ctrl/Cmd step goes to the next tenth,
in the field's own unit. Repeated presses or notches of one size add or
take one step each, and presses and notches are applied in the order
they came. A Shift step still adds or takes ten and keeps the fraction.
The number fields that widgets.rs draws for the panels, the Control bar
and the dialogs (unit, percent, degree and count fields) all use this
step (widgets::step_value).
`vectorcraft-cli run/convert --scale` and the MCP export tool's `scale`
are raster pixels per point, but they were passed to every export, and
DXF reads `scale` as drawing units per unit: a 2x PNG next to a DXF
doubled the drawing and left it unitless ($INSUNITS 0). They now go
only to raster formats. DXF's own scale stays reachable through the
format options.
Floating groups on the canvas now always get a panel-colour backdrop from layout geometry when egui has not registered the area yet, and panel bodies paint their own fill so empty regions do not show through.
Co-authored-by: Cursor <cursoragent@cursor.com>
The built-in CMYK profiles embedded in CMYK exports had only the
perceptual and colorimetric tables. ICC.1 requires an output profile to
also have the saturation tables (A2B2, B2A2) and the gamut tag (gamt).
Windows colour management rejects the profile without them ("Bitmap color
context is not valid"), so Microsoft Photos couldn't show a CMYK TIFF
that embedded it, while the RGB export opened fine.
Write the saturation tables (same as colorimetric) and a 9x9x9 gamut
table (in gamut where a Lab colour separates and returns within dE 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anchor_arc_distance measured every segment again for each anchor, so a reshape cost the square of the anchor count; path.reshape now measures the positions once, and the two helpers only it used are gone. The default radius stays half the subpath's bounding-box diagonal, now measured along the path: half its length reached the far corners of a square, moving art the grab is nowhere near (reshape_moves_the_grabbed_point_and_its_neighbourhood).
Found while testing crates/doc/src/lib.rs:274 (Unit::named)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
path.freehand takes redraw: {id, within}: the stroke's ends are placed on the path, anchors added there, and the section between them replaced by the fitted stroke, the shorter way round a closed subpath; a stroke that doesn't end on the path still draws a new one. The Pencil and Paintbrush send it for a stroke that starts on a selected path's outline away from its ends, within Edit Selected Paths' distance.
The cards turned to the chosen orientation in every category, so a portrait and a landscape A3 in Recent looked the same and both read as chosen. Only the built-in presets' cards turn now.
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.
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.
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 <cursoragent@cursor.com>
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.
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.
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DukHNvUY45rjRnvtz91x33
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).
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dt49z1ubK9x1bPq51APgz3
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dt49z1ubK9x1bPq51APgz3
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 <noreply@anthropic.com>
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.