After the desktop save panel closes, the app adds the file type's
extension to a name typed without it (or with one the save does not
write). The panel asked only about the name as typed, so on Linux
typing `card` with `card.vectorcraft` in the folder wrote over
`card.vectorcraft` without a question. On Linux the save panel is the
XDG desktop portal, and its GTK and GNOME file choosers return the
name as typed. Windows and macOS add the extension themselves only
when the panel lists file types, and the panels of Document Info's
Save, the presets dialogs' Export…, the CSS Properties export, the
swatch and graphic style library saves and the Libraries panel's
export list none.
When the added extension names a file that exists, Save, Save As, Save
a Copy, Save as Template, the exports and the other writes through a
save panel now ask "“card.vectorcraft” already exists. Replace it?"
first (dialog `replaceFile`). OK runs the write again with the same
answer and replaces the file. Cancel writes nothing and shows the
dialog it was asked over again. The host reports whether a file exists
through a new `Services::exists`.
Docs: docs/control-protocol.md, docs/target-app-parity.md.
A document assigned a CMYK profile (Edit > Assign Profile) keeps it when
it is saved, but the working spaces are the app's, and a new session
starts with the defaults. Opened again, such a document exported its CMYK
colours tagged with the working CMYK space (VectorCraft Generic CMYK
(SWOP-like)) instead of its own profile, made that profile the PDF/X
output intent, and separated its RGB colours through it; print
separations did the same. edit.assignProfile still reported the assigned
profile, and document.exportPdf's documentation says CMYK colours are
tagged with "the document's CMYK profile".
Found on a book cover assigned FOGRA39L Coated: built in several
vectorcraft-cli runs, each reopening the saved document, its PDF/X-4
came out tagged with the SWOP-like profile unless every run set FOGRA39
as the working CMYK space again.
Export and print now work in the document's colour spaces: the active
settings with the assigned profiles in their place (document_cms). An
assigned profile that isn't available leaves the working space, with a
warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The toolbar listed the Touch Type tool (Shift+T), but choosing it did
nothing. The reference app's Touch Type tool moves, scales and rotates
one character of live type by dragging the handles of a box around it.
A click with the tool now selects one character and shows a box around
it. Dragging the box or its lower-left handle moves the character along
its line and up or down. The upper-left handle scales it vertically,
the lower-right handle scales it horizontally, the upper-right handle
scales it proportionally, and the upper-middle handle rotates it. A
character rotated by up to about 84 degrees follows the pointer too.
Near a quarter turn, a drag moves it only along its line. Escape
cancels a drag, and otherwise lets go of the character.
Each drag is one text.touchType command and one undo step. It sets the
character's baseline shift, scales and rotation, and moves it along its
line by changing its own kerning and the kerning of the character
before it, within the kerning range. The characters after it stay
where they are. Where the font kerned the character with its
neighbors, the two kerning values also keep the character in place
when it is only raised, scaled or rotated. The characters after it
stay where they were, or move by the change in its width when it is
scaled. The first character of a line and a right-to-left character
move only up and down. The type stays editable.
Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
File > Place read text files only as plain `.txt`, and an RTF file
could not be placed. The reference app lists RTF among the formats it
places as text, keeping the file's font and style settings.
File > Place now reads RTF files, written from the Rich Text Format
Specification 1.9.1, and sets their text as area type the way a text
file is placed. Each run keeps its font from the font table, its size,
bold and italic, underline, strikethrough, all caps, small caps,
superscript or subscript (in Document Setup's proportions), baseline
shift and its color from the color table. Each paragraph keeps its
alignment, left, right and first-line indents, space before and after,
and direction. Table cells are separated by tabs and each table row is
a paragraph. Unicode characters, Windows-1252 and Mac Roman bytes and
the special-character control words are read, and of a \upr group's
ANSI and Unicode text, the Unicode text is read. Hidden text, pictures,
objects, headers, footers, notes, field instructions and other
destinations are left out, and unknown control words are skipped.
A file is read as RTF when it starts with {\rtf, whatever its name,
except a .txt file, which stays plain text. RTF files share the 4 MiB
limit of text files. Groups nested more than 512 deep share the
formatting of the deepest one read, the font and color tables keep at
most 4,096 entries, and a file with more than 100,000 paragraphs or
runs is refused. The Place dialog lists RTF files, and file.place,
file.place.info and file.place.queue report the format `rtf`.
The tests in tests_textimport.rs fail without the change, and
text_import_fuzz.rs places random RTF token streams.
Docs: docs/file-format-parity.md, docs/mcp.md,
docs/target-app-parity.md.
A Shift-click or Ctrl/Cmd-click on a brush in the Brushes panel used to
apply the brush, as a plain click does. It now selects brushes without
applying one. Shift-click adds the brushes from the last brush clicked
without Shift (else the highlighted brush) to the clicked one, in the
order the panel shows them. Ctrl/Cmd-click adds the clicked brush to the
highlighted brushes or removes it. The panel highlights the selected
brushes, and the bottom bar's Delete Brush and the panel menu's
Duplicate Brush and Delete Brush act on all of them. A plain click
works as before and ends the multi-selection, and so does selecting
other art, another document or another current brush.
brush.delete and brush.duplicate also take {names: [name...]}. Each
call is one undo step, and an unknown name fails before anything
changes. brush.duplicate answers {names} for a names list.
Docs: docs/mcp.md, docs/target-app-parity.md.
The reference app has a Blend panel, opened with Window > Blend, that
makes and edits blends in one place. VectorCraft had the Blend tool,
Object › Blend and the Blend Options dialog, but no Blend panel.
Window › Blend opens a panel with Make, the selected blend's Spacing and
Orientation, Reverse Front to Back, Reverse Spine, Replace Spine, Expand
and Release. Each control runs its Object › Blend command, so a change
of the selected blend's spacing or orientation shows on the canvas at
once and is one undo step. With two or more objects selected and no
blend, Spacing and Orientation set the options new blends start with,
as Blend Options does. Make is dimmed until two or more objects are
selected. Spacing and Orientation are dimmed until a blend or two or
more objects are selected, and the other buttons are dimmed until a
blend is selected. The Blend Options dialog's orientation toggles now
have names for screen readers.
Docs: docs/target-app-parity.md, docs/ui-parity.md.
The errors of the four blend spine commands (Move Spine Point, Reshape
Spine Segment, Add Spine Point and Delete Spine Point), which Direct
Selection, the Pen and the Anchor Point tool send, had no @msg row in
any catalog, so an error that reached the status bar, for example from
a Pen click, showed in English. The scan in
complete_languages_translate_every_message does not read messages
built with bad(), so that test did not report them.
Each of the 13 messages now has a row in the 16 catalogs that translate
every status and error message, in Bulgarian and in Simplified Chinese.
The rows that name the spine use each catalog's term for it. No English
string changes.
Docs: docs/target-app-parity.md.
A small closed loop whose fit collapsed to a single cubic (fit_closed),
or a piece returning to its own junction with fewer than two fitted
cubics (fit_piece), kept every positioned point as a straight segment.
A looser Paths collapses more loops, so it could give more segments
than a tighter one.
Such a loop now tries the fewest segments that still hold it, each only
if the result keeps an area (more than 0.25 px^2) and stays within
1.5 * max(tolerance, 0.5 px) of the points both ways: the fit at up to
three somewhat tighter tolerances (what a slightly higher Paths gives);
for a closed loop then two cubics split at its start and its farthest
point, then the outline simplified at the tolerance and finer. The
positioned points stay the last resort, as before.
Test (code-drawn): a_higher_paths_never_gives_fewer_segments sweeps
Paths 0/25/50/75/100 over 32 seeded 48 px images of soft blobs plus
pixel noise in Color and Black and White at Noise 1 and 3. Before, six
runs had fewer segments at a higher Paths (e.g. 125 -> 79 from Paths 0
to 25); after, none. One image (seed 16, Black and White: 140 -> 136
from Paths 0 to 25, with or without this change) has another cause and
is left out of the sweep.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
Where the pixel at a sharp corner falls to the other colour, the outline
cuts the corner in two one-pixel steps, and between two straight edges
of three cracks or more subpixel::outline makes those steps one point
(a notch). Whether the edges were long enough was measured on the whole
loop. An edge can run on past a junction in one region's loop while
the region across it, whose loop turns at that junction, sees a shorter
edge: one side made the notch and the other did not, so the shared
piece got different points on each side and the abutting curves left a
gap.
The notch now also requires the three cracks of each edge next to it to
lie in the piece (no junction among them), as the corner-pixel shift
correction above it already does.
Test (code-drawn): noisy_colour_images_leave_no_sample_uncovered traces
20 seeded 36 px images of soft overlapping blobs in four colours plus
pixel noise (Color, 5 colours, Noise 1 and 2, Paths 0/50/100) and needs
every one of five sample points per pixel to lie in some path: 93
uncovered before (seeds 9, 11, 13), 0 after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
Layers::shift read a crack's sub-pixel shift from its inside pixel,
looking for a thin run on the inside before one on the outside. Where
both sides are thin (a one- or two-pixel sliver beside another, as in
soft gradients and noisy scans) the two regions of the crack got
different shifts, so their shared piece got different points and the
abutting curves left slivers that no path covers.
The shift is now the mean of the readings from both sides,
(s(c) - s(c flipped)) / 2, which is exactly antisymmetric in floating
point (Crack::flipped gives the crack seen from its other side).
Tests (code-drawn images):
- a_crack_between_thin_slivers_has_the_same_shift_from_either_side:
every crack's shift is the exact negative of its flipped crack's, on
a noisy ripple of 1-2 px columns at three thresholds.
- shared_boundaries::thin_slivers_on_both_sides_of_an_edge_leave_nothing_uncovered:
a 48 px noisy ripple in Black and White at thresholds 140/157/170 and
Paths 0/50/100, sampled on the shared-boundary check's 4x4 grid per
pixel: 482-1,228 uncovered points per trace before, 0 after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
With Method Abutting and Shapes on, a region recognised as a shape had its
outline replaced by the exact shape, but the region across that edge kept
its fitted curve unless it was a hole filled by one colour, and even then
the hole was recognised on its own, with its region's combined hardness.
A shape touching a junction (a third colour, or its own colour at a
corner) or the image's border was replaced on one side only, so abutting
colours parted or overlapped along the shared edge, and a square on the
image's corner could reach past the frame.
Cause: Layers::layer recognised an island with no holes
(`[only] => shapes::recognise`) regardless of what lay around it, and a
hole only by `one_colour_within`, each from its own outline and hardness.
Fix: under Abutting a shape is applied only where both faces take it
(`Layers::shared_shape`): the loop has no junction on it and does not
touch the border, the island has no holes and the hole holds one colour,
and the shape stays inside the image. The shape is recognised from the
outline turned one way and started at its leftmost anchor, with that
loop's own hardness, so the island and the hole it fills get the very same
shape. Overlapping is unchanged.
Test: shapes_keep_abutting_neighbours_watertight traces three code-drawn
anti-aliased colour images (a disc in a square plus a disc across its
edge, shapes on the border, tilted tiles) and checks every outline lies
within 0.001 px of a neighbour and a 4 x 4 per-pixel grid is covered
exactly once. Before: gaps of 0.50, 15.1 and 0.73 px, 183 and 276 points
uncovered or doubled; after: 0 px, 0 points. Isolated shapes are still
recognised.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
Flat Logo found its colours only from solid pixels (a uniform 3 x 3
neighbourhood). Line art on paper has no solid area but the paper's: a
stroke one or two pixels wide never fills one, so its colour was a handful
of pixels among tens of thousands of white and was cut as noise (under
0.1% of the solid pixels). The palette came back empty and trace_logo
returned an empty TraceResult, which imageTrace.make reported as a success
with paths 0, anchors 0, colors 0.
Cause: logo::palette::auto_palette had no way to find a colour that has no
solid area, and logo::pipeline::trace_logo treats an empty palette as
nothing to trace.
Fix: when the solid pixels give no colour but white, auto_palette reads
the colours from the strokes' cores instead (line_art_palette): the opaque
ink pixels at least as far from white as each of their four neighbours,
the middle of each stroke where it is least blended with the paper.
Two guards keep that fallback to line art:
- Ink is a colour at least 48 below white in one channel, and the page
must have ink on at least 1% of its opaque pixels. Light grain and a few
sparse marks on otherwise blank paper stay blank, as plain white does.
- The lighter shades along a stroke's anti-aliased edge are local maxima
of their own; a colour that lies on the line from white to a darker
colour of the palette (within 20) is a blend of that ink with the paper
and is folded into it (fold_fringes), the ink keeping its colour.
The rest of the pipeline (unmixing, the flat-colour check and its NotFlat
refusal) is unchanged, and images with a solid colour other than white
never reach the fallback.
Tests (code-drawn inputs):
- thin_line_art_on_paper_is_traced_not_blank: four 2.4 px dark strokes
and two red marks on white. Before: palette [], no paths. After: dark
and red paths along the middle of every stroke and mark.
- anti_aliased_coloured_lettering_has_only_its_inks: rings, slants and
bars 1.6 px wide in red, blue and green. Without the fold: six colours
besides white; after: exactly the three inks.
- sparse_marks_on_paper_are_not_promoted_to_colours: 24 grey 12 x 2 px
marks of 239, 223 or 191 on a 256 x 256 page (under 1% ink). Without
the guard: palette [white, #efefef]; after: empty palette, no paths.
- grainy_blank_paper_has_no_ink: paper grain 243-255 traces to nothing.
- edge_shades_fold_into_their_ink_and_other_colours_stay (unit).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
contour::trace_mask built a table of outgoing directions for every
pixel corner in a raster pass and then followed it. It now works out
each turn during the walk from the two pixels ahead of the current
corner: right if the one ahead on the right is outside, straight if the
one ahead on the left is, else left (so at a saddle it still turns
right and diagonal pixels stay apart). Loop starts are found by
scanning the pixels for a top edge on the outline, with one flag per
pixel for a top edge already walked. The walk is bounded by the grid
size, and a mask of the wrong length now gives no loops instead of
failing an assert_eq!.
The output is identical: the_walk_equals_the_stored_direction_walk
compares it with a test-only copy of the previous walk on all 65,536
4x4 masks and 400 seeded 23x17 masks.
Time (best of 7, release, 4 cores): the walk alone on 2000x2000 masks
19.1 vs 26.6 ms (smooth pattern), 81.3 vs 93.2 ms (12% random pixels),
223.1 vs 238.1 ms (50% random); whole 1000x1000 traces 73.1 vs 77.1 ms
(Black and White), 249.6 vs 251.7 ms (Color, 6 colours), 214.1 vs
218.6 ms (Color, 6 colours, Noise 1), same segment counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tBcdwNhptqtmX32JCGFzB
An envelope is written on `%_` lines as a group holding the mesh that shapes
it, with the distortion (`AIDeformStyle`, `DeformValue`) in the group's
dictionary; the distorted art the PDF part draws follows on plain lines. The
reader kept that group, so the mesh drew as a grey gradient mesh behind the
art. A group on `%_` lines that holds only meshes and carries a distortion is
now dropped; a mesh with no distortion is still art.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P6znVFRqGqvB9f7njwZ46r
A .ai or EPS opened from its editing data lost the opacity and blend
mode of its text objects. text_slot made the slot without the graphics
state's opacity and blend mode, which paths, meshes and images take, so
an Xy before a text object was lost. An Xy after it reached the slot,
but fill_slots then put type made from the text document in the slot's
place with only its visibility and name, and style_marker dropped the
transparency that a text object with an appearance carries on its look.
text_slot now takes the state's opacity and blend mode. fill_slots
gives made type the slot's opacity, blend mode, isolation and knockout.
The page's type gets them too, in place of its own transparency when
that is none, the slot's, or the slot's combined with that of the
objects around it; otherwise the page's type and the strokes around it
keep their own (a fill's, for example). When the type has transparency
and comes with other art (the strokes the page draws around it, or page
type that goes with it), a group of them takes the transparency. A text
object with an appearance takes the transparency from its look; where
the look draws more than the type and has transparency, the look and
the type go into a group that has it, and that group is not taken as
the look of a later object. The import fuzz's .ai tokens now include a
commented text object, 1 /FreeUndo , and a group's state marker
(6 () XW).
Docs: docs/mcp.md, docs/target-app-parity.md.
Shift pressed while dragging a segment had no effect on the curve:
Direct Selection's curved-segment and spine-segment drags, the Anchor
Point tool and the Pen with Alt sent the pointer offset alone, and the
segment bent freely. The reference app keeps the handles in the
perpendicular direction and of equal length while Shift is held.
path.reshapeSegment and object.blend.spine.reshapeSegment take
perpendicular (default false, which keeps today's behavior). With it,
both of the segment's handles stand at right angles to the line between
its anchors, on the same side and at the same length, and the point at
t lies as far from that line as the moved point does. A smooth anchor
at either end stays smooth, as it does in a drag without perpendicular,
and a segment whose anchors coincide bends as it does without
perpendicular. With Direct Selection, Shift pressed once a curved
segment's drag has started sends perpendicular; Shift held at the press
still toggles the path's selection, as before. Direct Selection's
spine-segment drag and the Anchor Point tool's segment drag send Shift
as perpendicular too, so the Pen with Alt does as well. A straight
segment dragged with Direct Selection already moves at 45° steps with
Shift.
Docs: docs/mcp.md, docs/target-app-parity.md.
Shift+Enter in a panel or Control bar field ended its editing, as Enter
does. The reference app's default keyboard shortcuts list "Apply value
and keep text box active" under Use panels and "Apply a value and keep
focus in edit field" under Transform panel, both Shift+Enter on Windows
and Shift+Return on macOS.
- A focused single-line field of the shared widgets (num_field,
mixed_field, plain_field, range_field, spin_field, spin_field_auto,
spin_plain, text_field, exact_text_field, opt_field and hex_field)
takes Shift+Enter before its text box reads it, applies the text as
Enter does and keeps the focus. In the next frame it shows the value
as it now is, all selected, so typing replaces it.
- A text box of several lines keeps Shift+Enter as a line break.
- While a dialog is open, Shift+Enter applies the field's value and
confirms the dialog, as before. The app sets widgets::set_dialog_open
at the start of each frame.
Docs: docs/target-app-parity.md.
The Paragraph panel menu's Reset Panel turned Hyphenate off but left
the Hyphenation options: Words Longer Than, After First, Before Last,
Hyphen Limit, Hyphenation Zone and Hyphenate Capitalized Words. A
paragraph kept its custom hyphenation limits after a reset.
Reset Panel now also sets those six options to the values of
HyphenationOptions::default(), in the same text.setFormat call. The
reset paragraphs then save no hyphenation options.
Smart Blur's Edge Only and Overlay Edge modes marked a pixel when one
of its four nearest pixels differed from it by more than Threshold,
and Radius changed only the blur. The reference app's documentation
says that Radius sets the size of the area searched for dissimilar
pixels.
A pixel is now on an edge when one of the pixels the search reads
within Radius differs from it by more than Threshold and its
channels, alpha included, add up to at least that pixel's. The search
picks its neighbors as the Normal blur does, over the whole pixels of
Radius and within it, and always reads the four nearest pixels.
A color step gives a line about as wide as Radius on the side with
the larger sum, and a Radius of one pixel or less gives the same
edges as before. The edge modes' reach() includes the blurred copy
that a wide Radius reads.
On a 2,000 by 2,000 px image, Edge Only takes 0.49 s at Radius 10 and
0.34 s at Radius 100, and Normal takes 0.73 s and 0.39 s. Normal
draws the same pixels as before.
Docs: docs/target-app-parity.md.