A tile was kept while the pattern's art and what it shows were unchanged,
but a brush the art is stroked with lives in the document's other data, and
every tile is drawn through the color settings. A tile whose art reads more
than itself now also remembers the document's other data, and every tile
remembers the color settings it was drawn through (both weakly).
A scatter brush whose art is an instance of a symbol stroked with that brush, or any brush whose
art is painted with a pattern whose tile is stroked with it, drew itself without end: a stack
overflow in a hand-written native file. Brush pieces already strip the brushes of their own
strokes; now what they draw by name that would draw a brushed stroke (a symbol instance, or a
pattern paint, also through the symbols and patterns those use) is left out too, in one place
(`stroke_pieces`, which every renderer and exporter calls with the document).
Probed the rest of item 2 of the depth follow-up with hand-written hostile inputs, kept as tests:
graph designs of deep art and of a symbol holding itself, Affinity groups nested to and past
both of its reader's caps, DXF blocks inserting themselves, EMF/WMF saving states without end,
and plug-in manifests and output nested 30,000 to 100,000 deep. None of them overflowed.
The decoder's 512 MB limit is on the file's samples; a grey image within it
is four times that once converted to RGBA for the Eyedropper or a recolour,
which the web build can't allocate. The size is now read from the header and
held to the allowance the importers and the renderer use.
A native file (or a pasted document) whose node, slice or asset ids or whose
next-id counter sit at u64::MAX made the next allocation overflow: a panic
with overflow checks, a wrapped counter handing out ids already in use
without. fix_next_id now leaves ids and counters from 2^62 up out of the
count, so new ids start under them; alloc_id saturates. Artboard and graphic
style ids take the smallest free id when the largest is u32::MAX instead of
wrapping to the reserved 0.
A mask's art lives outside the layer tree (in the masked object's extras),
and the walks over linked colours skipped it: editing a global swatch
recoloured everything but the mask, which kept showing (or hiding) as
before, and a swatch used only in a mask counted as unused. The colour
walks now go into opacity masks: map_solid_paints (swatch edits, renames,
deletes, Merge and the like) and visit_paints (unused swatches).
Every symbol instance was measured as its transform applied to the
20 × 20 square around the origin, which symbols made in the app keep
their art in. The SVG and DXF importers keep symbol art as drawn, so
the box of an instance of an imported symbol did not match its art.
The SVG Star instance at x 20, y 30 draws from (20, 30) to (40, 50)
and was measured from (10, 20) to (30, 40). Align moved such an
instance by the wrong amount, clicks and marquees beside its art
selected it, and the canvas, exports and prints left it out when its
art reached the view or the page and the square did not.
An instance is now measured by its symbol's art, which
Document::symbol_boxes looks up in the document each time
(Node::geometric_bounds_with, visual_bounds_with and
reach_bounds_with). With Use Preview Bounds on, the strokes in the art
count at their own weight, as the canvas draws them, and
object.setBounds counts them at that weight when it sizes an instance
or an object that holds one. When strokes do not scale and a width or
height of 0 or more is smaller than what a path's stroke adds,
object.setBounds now gives the path no width or height, as it does
for an instance whose art has strokes; before, it mirrored the path.
With Use Preview Bounds on, object.setBounds now measures the margins
that strokes add object by object, the members of groups and the
objects in symbol instances' art included, and solves for the width,
height and reference point asked for when the width and height are 0
or more. Before, it measured the margins of the whole selection and
could miss the size or the reference point asked for when strokes did
not scale and the objects' strokes added different margins, and when
strokes scaled and the width and height were out of proportion, even
for one path. A clip group, a Shaper Group, a compound path, type, and
a group or instance with effects or an opacity mask of its own are each
measured as one object, and so is an instance in perspective or one
turned or slanted relative to the bounding box, other than by quarter
turns. When the selection holds more than 4,096 objects to measure this
way, each selected object is measured as one. Instances inside symbol
art are measured by their own symbol's art. An instance is measured as
the square when its symbol is missing, when its symbol or a symbol
nested in its art holds an instance of itself, and when symbols nest 32
deep in its art.
Align and Distribute, the Transform panel, the bounding box and the
selection outline, clicks, the marquee, document.inspect and
document.node, Fit to Artwork Bounds and Fit to Selected Art, canvas
culling, thumbnails, the SVG filter region of an instance with a
raster effect, raster effect images, Export Selection, the SVG of an
artboard, printing, and the PDF, EPS, EMF/WMF and DXF writers use the
new box. The renderer also culls an instance by the effects and
brushed strokes in its art, and it culls a blend, envelope or repeat
by the brush art of what it holds and the effects in the art of the
instances in it. It culls an instance with geometry effects of its own
by the effects in its art after those geometry effects reshape the art.
It measures the effects in a symbol's art once for each symbol and
linear part of the transform that places it. For symbols whose art
holds instances, it measures at most 4,096 placements a frame, and at
most 4,096 more for each pattern tile it rasterizes and each placed
document it draws. Past that, it estimates the reach from the symbol's
reach with no transform, which it measures once. When the drawn
document's symbols change, the renderer drops the cached bounds of the
instances and of the objects that hold them.
The PDF and EPS writers, the SVG of an artboard and Skip Blank
Artboards grow the box of an instance whose art holds brushed strokes
by 16 times the widest stroke (at least 1 pt) of the objects in that
art that have a brushed stroke, plus 8 pt, as the renderer grows the
cull bounds of a brushed path. They keep an instance whose brush art
reaches the page. The SVG of an artboard also keeps an object that
holds an instance when what the canvas paints for it, the effects in
the symbol's art and the instance's own included, reaches the artboard.
Not yet: other commands that measure objects, such as Select › All on
Active Artboard, Transform Each and Convert to Artboards, still measure
an instance by the square.
Docs: docs/mcp.md, docs/target-app-parity.md.
objects_at() found Select Behind targets by running hit_test_skipping()
in a loop, skipping everything found so far. Each round is a full tree
traversal, so a stack of n overlapping paths costs O(n^2) hit tests
before the loop finds no further object.
The fast path walks each editable top-level layer once. Every selectable
object's subtree is visited only until its first hit, and a HashSet
records found ids so subtrees already covered by an earlier hit are not
searched again. Sublayers recurse the same way, so a stack of n paths
costs one traversal total.
The original traversal stays in charge where Select Behind semantics
differ from the plain front-to-back order: isolation (scope), and Edit
Contents, which exposes objects nested inside a top-level group. Non-
layer roots in malformed files also fall back. objects_at_repeated()
keeps the old loop for those cases.
Three tests: a 256-path stack asserts front-to-back order and
completeness; a mixed document (sublayer, groups, clipping group,
outline and path-only options) asserts the one-pass result equals the
repeated-traversal result; an envelope in Edit Contents asserts the
fallback preserves the original order.
warp_point worked on box coordinates normalised to -1..1 on both axes:
Arc's radius was in x units but its edges were offset in y units, so a
box that isn't square bent into no circular arc (skewed sides from
Bend 10 %), and at 100 % the radius (2/π) was smaller than the
half-height, folding the inner edge over. warp_point now takes the
box's aspect along the warp; Arc keeps the centre line's length on a
circular arc in real proportions, with concentric edges, and an inner
edge that would pass the centre stops there. Other styles are as they
were; a square is bent as before.
Fixes#1578
File > Place, Relink and Open refused a grayscale TIFF whose pixels carry an alpha channel or
another extra channel: the image decoder reads neither. They are now read like CMYK TIFFs with
alpha: the first extra sample is the transparency when the file says it is alpha (associated alpha
divided back out), and other channels are left out.
Delete, Cut, Group, Make Compound Path, Make Clipping Mask, Send to
Current Layer, Ungroup and Release Compound Path removed, inserted or
moved one object at a time. duplicate_in inserted one copy at a time;
Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs,
Transform Again after a copy, Transform Each with Copy, the perspective
copies and each move of an Alt-drag call it. Each insertion or removal
moved the later objects of the same layer or group to other indices.
The next lookup by id then walked the tree, and after every fourth such
walk Document::index_path rebuilt the whole lookup cache. Each command
did work that grew with the number of objects times the size of the
document: with 25,000 rectangles selected, a release build of
vectorcraft-cli took 7.2 s to group them and 11.5 s to copy them with
object.transform.
Document::remove_many, Document::wrap and Document::insert_beside
remove nodes, wrap them in a new node, or put nodes directly above or
in place of their anchors, in one pass over the children of each
parent. Each finds the nodes with one index_paths call, checks every id
before it changes anything, and clears the lookup cache once. The
commands above use them. The objects keep their order, the copies take
their ids in the same order, and the selection, the undo step and the
journal are the same. duplicate_in still copies one root at a time when
a root is listed twice or lies inside another root. A release build
with this change groups the 25,000 rectangles in 27 ms and copies them
in 121 ms.
Docs: docs/target-app-parity.md.
With Double Click To Isolate on, the Selection tool isolated groups only. A
double-click on a single path, compound path, image, mesh or placed file now
isolates it too, as the public documentation describes. Type still switches to
the Type tool; blends, envelopes, repeats and symbol instances keep their own
editing modes.
While a single object is isolated only it is hit, marqueed and taken by Select
All, new art goes beside it in its group or layer, and a double-click on the
empty canvas leaves with it selected. object.isolate accepts any object except
type.
A background drawn at the artboard's own position could sit 3e-14 pt outside it and
stay behind when the artboard moved, was copied or scaled its art. Art within a
millionth of a point of the artboard's edges now counts as inside it.
The fix and both tests are from #1295 by Luca Lorenz, without its change to art that
only partly overlaps the artboard.
Co-authored-by: Luca Lorenz <[email protected]>
Save serializes JSON straight into the compressor and writes images from their Arc into the
file; open reads each image into its own buffer, and NodeKind reads its tag first instead of
buffering every node. Already-compressed images are stored rather than deflated again in the
same gzip stream. Limits apply per section, so any file VectorCraft writes opens again. Data
Recovery writes each image once, content-addressed, instead of every interval. Native files open
on a background thread. Files stay readable by main both ways; JSON-only saves are identical.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
# Conflicts:
# crates/engine/src/cmd/graph.rs
# crates/svg/src/export.rs
History was limited by step count only; a whole-selection move of 50k paths keeps ~40 MB per
step. Steps now carry a size estimate (only unshared subtrees are visited) and the oldest are
dropped beyond a byte budget, keeping the last 8. Documents entering history drop their id
index, selections are shared between steps, the command journal is bounded (held while an
Action records), and document resources are copy-on-write. Saved files are byte-identical.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
Find in Folder… in Linked File Not Found and in the Links panel menu
looks in a folder the user picks, and in its subfolders, for the files
with the names of missing linked files, in any letter case. In the
Links panel menu it looks for the selected linked files when there are
any. It uses the folder search of the Missing Fonts dialog. Find
Linked Files lists each file it looks for with the files found for it.
A file with the size and contents saved with the link comes first. A
file of the saved size comes next when the link saved no hash or the
file is larger than 1 GB, and any other file of the name comes last.
Within each group, a file named in the same letter case comes first.
Then each file's folder names are compared with the saved path's, from
the innermost outward, up to the first one that differs, and a file
with more matching names comes before one with fewer. A file is chosen
when it is the only file found, or the only one with the saved
contents, and the user can choose another file or Don't Relink.
Relink relinks each file chosen, and each relink is one undo step.
With Copy into the document's folder, which shows for a saved
document, each image's file is copied next to the document first. The
file's name, then Name 2.png, Name 3.png and so on, are tried in turn
until one is free, which gets the copy, or is a file with the same
contents, which is used. A placed document is relinked where its file
is. The status line names the first file that fails to relink, and the
other files still relink. When Find Linked Files opens from Linked
File Not Found, the missing files left without a choice, and any that
fail to relink, go back to that dialog. On the web, Find in Folder… is
disabled in the Links panel menu, and links.findFiles can't start a
search.
- links.findFiles searches on separate threads and returns at once;
links.findFiles {} reports the search, and {stop: true} stops it. It
looks for at most 1,000 linked files at once: the active document's
missing ones, or those of the objects it is given. It hashes a file
only when a link of its name saved the file's size and a hash, and
only when the file is 1 GB or smaller. It ends once every linked
file has its same file, and it searches only the folders
text.findFontFiles may search, with the same maxSeconds, depth and
count limits.
- links.relink {copy} copies the file it is given into the saved
document's folder and links images to the copy; a call that includes
a placed document fails. Each name it writes is checked against the
automation write root. text.addFontFiles and links.relink {copy}
make their copies with fileio::copy_new, which never replaces a
file. The text.addFontFiles params text and docs/mcp.md say which
names are tried for a copy.
- vectorcraft-doc adds links::hash_reader, which hashes what a reader
reads as hash_bytes hashes bytes.
- ui.findLinksInFolder is the Links panel's Find in Folder… for agents;
docs/control-protocol.md lists the dialog's fields.
Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md.
Text formatted in a graph, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/format-the-text-in-graphs.html
describes (the whole graph with the Selection tool, or labels picked
with Group Selection, through the Character and Appearance controls),
now survives the graph being drawn again from its data.
- A graph keeps a character style per kind of label: value axis
numbers, category labels (category axis, radar spokes, pie names)
and legend labels (beside or across the top, or in wedges).
- text.setStyle, text.setFormat and fill/stroke changes on a graph's
labels apply the same change to their kind's kept style, so only what
the user changed is kept and every label of that kind follows. A
change to part of a label's text isn't kept.
- Labels are told apart by where the graph draws them, not by their
text, so a category named "2" and the value axis' 2 stay apart.
- Label spacing follows a kept size (legend rows, offsets from the axes
and wedges), so bigger labels don't overlap; at 9 pt it's as before.
Wedge labels stay white on dark wedges unless given a colour.
- A kept style from a file is held to the Character controls' limits.
Own choice, as the page doesn't say what happens on a data change:
formatting one label formats its whole kind, since the labels are drawn
again from the data. Other ways of restyling text (eyedropper,
character styles, Find Font) aren't kept yet. New fields are written
only when set; graphs without kept formatting draw the same art as
before.
Graph Type's column, bar and cluster widths from 1% to 1000%, overlapping
past 100%, the overlap order, and Draw Filled Lines, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/format-columns-bars-and-lines.html
describes.
- Column and cluster widths take 1–1000% and are drawn as set: past
100% columns (or clusters) overlap. The legend moves past columns
that reach beyond the plot.
- firstColumnInFront draws the first series over the others (in each
layer); firstRowInFront draws each series' first category over the
next. Across series the series order decides, so each series stays
one group for Group Selection.
- filledLines draws the connecting lines of line, scatter and radar
graphs as 3 pt bands filled with the series colour (set as a fill or,
on a thin line, a stroke) with a thin black outline. A run with no
outline to make, or a graph past 20,000 data points, keeps thin lines.
- The Graph Type query lists the overlap options only for graphs with
columns or bars, and filled lines only for graphs with lines.
Own choices, as the page doesn't say: both overlap options start off
(today's order), and the band is 3 pt wide. New fields are written only
when set. Graphs with default options draw the same art as before.
Graph Type's Add Drop Shadow, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-drop-shadows.html
describes, and Add Legend Across Top, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/change-the-position-of-legends.html
describes.
- dropShadow: a shadow behind each column, bar, stacked segment, line
(line, scatter and radar) and pie wedge, just behind the series of
its layer, so areas and columns drawn in front don't hide the shadows
of the layer behind them.
- legendAcrossTop: the legend's swatches and labels left to right from
the plot's left edge, wrapping at the plot's width, the last row just
above the plot and anything drawn over it (radar and stacked-pie
labels).
Own choices, as the pages don't say: a shadow is a black copy 2 pt down
and right at 35% opacity; markers, areas and legend swatches cast none,
nor do stacked segments of 0; a graph with more than 200,000 shadowed
marks draws none.
New fields are written only when set. Graphs with default options draw
the same art as before.
Graph Data's Transpose row/column and Switch x/y, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html
describes, a `|` in a label as a line break, as
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-labels-and-data-sets.html
describes, and the pie graph options of
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/format-pie-graphs.html
- graph.setData {transpose: true} swaps rows and columns (after any new
data in the same call); a graph with more categories than a graph can
have series (256) is refused rather than cut. {switchXY: true} swaps
each scatter series' y and x columns, labels included.
- A `|` in a category or series label starts a new line: legend rows
grow with their lines, labels beside an axis or in a wedge centre
theirs. A label of only `|` is blank.
- Pie graphs: pieLegend none|standard|wedges (Legends in Wedges puts the
series labels inside their wedges, in white on dark wedges, drawn over
the pies), piePosition ratio|even|stacked and pieSort all|first|none,
as dropdowns in the Graph Type dialog, which lists them for pie graphs
only.
Own choices, as the page doesn't say: Position and Sort keep today's
drawing by default (Even, None); Ratio sizes pies by area, the biggest
keeping the full size; Stacked pies share a centre, each drawn as the
ring outside the next smaller one so none covers another, with their
names over them.
New fields are written only when set. Graphs with default options draw
the same art as before.
Graph designs are named art a graph draws in place of its default
marks. Marker designs follow
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/apply-marker-designs-to-graphs.html
and the Graph Design dialog
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/reuse-graph-designs.html
- graph.design saves the selected art as a named design (Save Design),
pastes a copy of a design into the document, selected, to edit and
save again (Paste Design), and deletes one; graphs drawing a deleted
design go back to their default marks.
- graph.marker draws the data points and legend swatches of the series
picked with Group Selection (or listed, or every series) with a
design: its backmost object is scaled to fill the default marker's
square, strokes, effects and pattern tiles scaling with it. Line,
scatter and radar series; null goes back to the default square.
- Object › Graph › Design… and Marker… open dialogs listing the
document's designs.
Own choices, as the pages don't say: the backmost object's bounds, not
only a rectangle's, set the size (a single object uses its own bounds);
Delete Design manages the list; a design keeps its own paint, so
painting a series doesn't recolour it.
Designs are stored in a new `graphDesigns` list and series designs in
`seriesMarkers`, written only when present. A graph saved as a design is
kept as the plain art it draws. A design holds at most 2,000 objects, a
document 1,000 designs, and a graph whose designs would draw more than
200,000 objects draws default squares; a file's designs are tidied the
same way on open. Global swatch edits, image relinking and placed
documents see design art like symbol art. Graphs without marker designs
draw the same art as before.
Every edit of an object copied its whole appearance (its fills, strokes and effects, about
540 bytes for a basic object) into the new node, though a move leaves it as it is. Over 45
whole-selection moves of 10,000 paths, undo history grew by 589 MB; now 349 MB (41 % less), and
a nudge or a drag of the selection takes 12 ms instead of 15.
- Node::appearance is a vectorcraft_doc::Shared<Appearance>: copy-on-write. A clone shares it,
and the first change through DerefMut copies it (Arc::make_mut). Reads, writes, equality (also
against a plain Appearance) and the saved form are those of the Appearance itself, so call
sites read and change it as before. Sites that build or assign a whole appearance add .into(),
and the few that need a plain copy take (*n.appearance).clone().
- Node::transform changes the appearance only when it has gradients to transform, and pattern
paints are transformed only when there are some: a move no longer copies an appearance it
doesn't change.
The workspace suite (5,301 tests) and clippy pass with the change; the doc and engine suites
pass again after the transform guard.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
The previous commit rebuilt the cache as soon as a cached path went stale, and built it as one
Vec path per node, hashed with SipHash. An edit that removes many objects one by one
(Pathfinder Unite of 1,000 paths) went stale on every removal, and Unite took 105 ms instead of
42 ms.
- The cache is now one flat table of (parent entry, index among siblings) with a one-multiply
id hasher, so a rebuild is about one walk and allocates nothing per node.
- A stale path walks, as an id the cache doesn't have does, and rebuilds only after a few.
Either way a lookup stays within a small factor of a walk.
At 1,000 paths, Unite takes 45 ms against main's 42. At 100,000 paths the gains of the cache hold:
hit test 1.7 → 0.36 ms, handle drag 6.5 → 2.7 ms per move. The cached-lookup tests pass unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
A lookup by id walked the tree, so every edit, click and pointer move of a single object cost
time in proportion to the whole document. At 100,000 paths:
- hit test, per click: 1.85 → 0.35 ms
- handle drag, 5,000-anchor path, per move: 6.5 → 2.6 ms
- segment drag, per move: 5.8 → 2.7 ms
Document keeps every node's index path in a cache (id_index, never saved). The tree is edited
through public fields in many places, so the cache isn't kept in step with it; it is checked
instead. A cached path is used only while the node at that path still has the id, a check as deep
as the tree. A path that doesn't check out rebuilds the cache, and an id it doesn't have walks,
as before. A rebuild costs a few walks, so it happens only after a few such misses, which keeps
any edit pattern within a small factor of the walk. Ids that appear more than once are never
cached, so they keep their first place in tree order. index_paths uses the cache for small
batches and one walk for large ones.
The one case that answers differently from a walk: a second node with an existing id put in the
tree after the cache was built, earlier in tree order. The lookup then returns the original node,
also one with that id. The app doesn't make such nodes; damaged files with duplicates are cached
afresh when opened.
cached_lookups_follow_any_edit_of_the_tree checks index_path, node and index_paths against a
walk after inserting at the front, removing, swapping, moving between layers, deleting a layer,
and in a clone sharing the cache. Using cached paths without the check fails it. The doc and
engine suites (2,002 tests) pass.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
Every lookup by id walked the whole tree, and the edit path did one per selected id, so editing
a selection of n objects cost O(n²). Measured with the new `perf --deep` rows at 25,000 paths:
- Select All: 458 → 3.5 ms
- nudge the selection: 3,517 → 34 ms
- drag it with the Selection tool, per pointer move: 3,413 → 35 ms
- undo / redo: 407 → 3.1 ms
- vectorcraft_doc: Document::index_paths (every id's index path, in one walk; the first in tree
order for a duplicated id, as index_path), node_at, node_mut_at (copy-on-write, as node_mut)
and ancestry_at.
- apply_transform, roots_of, transform_patterns, doc_sane, Selection::prune and
Document::paint_order look their ids up with one walk instead of one each.
- Selection::set dedups with a hash set instead of Vec::contains per id.
- vectorcraft-cli perf --deep: tracked rows for editing a whole selection (select, nudge, drag,
undo, redo), live effects, every Pathfinder operation, typing in a long story, and memory.
tests_lookups checks the batched lookups against the one-id ones on nested trees with missing and
duplicated ids; storing the last duplicate instead of the first fails it. The doc and engine
suites (2,000 tests) pass unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
Adds the Character panel menu's Digits option (with the Middle Eastern /
Indic options, beside Paragraph Direction in the Paragraph panel) as a
character attribute: CharStyle::digits, saved only when set, with
text.setFormat {digits} and text.setRangeStyle {id, start, end, digits}
("default" | "arabic" | "hindi" | "farsi"; a bad value is an error).
Shaping draws every digit of the Western, Arabic-Indic (U+0660–U+0669)
and Eastern Arabic-Indic (U+06F0–U+06F9) sets in the chosen system,
whatever was typed, before font fallback, so a font without the
Arabic-Indic digits gets a fallback for them. The text, its clusters,
the caret, selection and search stay as typed, as with All Caps; SVG
export writes the digits as drawn.
Catalog rows for Digits, Arabic, Hindi and Farsi in the ten translated
languages. Docs: docs/mcp.md (Digits), type-parity.md (digit types
done; Character Direction, kashidas, composers and the split caret
still missing), target-app-parity.md and gaps.md.
Behaviour from the public documentation (a character-level option with
the values Arabic, Hindi and Farsi in the Character panel menu):
https://helpx.adobe.com/indesign/desktop/language-and-proofing/arabic-and-hebrew/choose-digit-types.html
Tests: doc (the mapping and serialization), text (glyphs of "page 3" in
Hindi/Farsi equal the typed Arabic-Indic digits' on the typed clusters;
skips without a font covering the three sets), engine (setFormat,
setRangeStyle, undo, error), svg (digits written as drawn), ui-egui
(the menu shows with the Indic options).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
A marquee drawn wholly inside a filled shape counted as touching it, so
a marquee over a background (the usual way to pick small objects off it
in Outline view) selected the background too. As in Illustrator, the
marquee now needs the path itself: an anchor inside it or a segment
crossing its edges.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The second half of #966. With Proportional Metrics on, a vertical segment is
also shaped top to bottom, with and without `vpal`: the difference gives
each glyph's change of advance down the column and its x and y placement.
An upright glyph (not tate-chu-yoko) in a font with vertical metrics takes
them on top of its cell, so it moves with its shorter cell; a font without
vertical metrics keeps `upright_cell`'s cell, and with the switch off
nothing is shaped twice and the column is as before.
As in horizontal type, Line-end Punctuation Half Width takes nothing more
off the punctuation `vpal` re-spaced and trims the punctuation it leaves
full height as before. Docs: mcp.md, and Proportional Metrics leaves the
missing lists in gaps.md, type-parity.md and target-app-parity.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Selecting two objects, clicking one to make it the key object and then
Shift-clicking the other left the remaining object as the key. The
canvas still drew that object with the thick key-object outline. The
Align panel aligned the object to itself, so the object did not move.
The key is now cleared after any change that leaves no other selected
object outside it, such as a Shift-click, or hiding or deleting the
other object's row in the Layers panel. On a single selected object,
or on a group selected with only its own members, select.key sets no
key.
With one object selected and Align to Key Object chosen, the Align
panel, the Control bar and the Properties panel ran object.align with
to: "key", which fails with "no key object" when the selection has no
key. All three now align a single selected object to the artboard
whatever Align To is set to.
Docs: docs/target-app-parity.md.
A character attribute (Character panel menu with the East Asian options;
`text.setFormat` / `text.setRangeStyle {proportionalMetrics}`), saved only
when on. In horizontal type it applies `palt`; vertical type sets as before
until the vertical shaping change (`vpal`) follows.
Line-end Punctuation Half Width takes nothing more off the punctuation
`palt` re-spaced (the flush opening bracket, consecutive punctuation, the
line-end half width): the segment is shaped once more without `palt`, and
the glyphs whose advance differs are marked. Punctuation the font leaves
full width is trimmed as before. SVG writes `"palt" 1` with the
character's other features.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
After scaling 12 pt type 2x, the Character panel still showed 12 pt and
typing 10 pt gave 20 pt type. Character controls and text commands now
work in document points: the size, leading and baseline shift shown and
set are the stored ones times the text's scale along its em height, and
horizontal scale includes the ratio of the transformed em axes. Rotation,
reflection and shear stay in the transform, so text remains editable.
Groups' text counts, keyboard increments use the same units, Shrink
Text to Fit works in document tenths, and a collapsed transform rejects
dimensional edits without touching the document.
text.getRange takes effective: true for the document-space values.
- Guides on a hidden or locked layer leave the selection (Selection::prune,
run after every edit and selection change), their Layers rows neither
select nor drag them, and guide.move / guide.remove / guide.setLayer
refuse them; guide.setLayer refuses a locked target layer, as dragged
rows are refused there.
- A new guide goes where new art goes (the current layer, else the top
shown and unlocked one; refused when there is none), so it never lands
on a hidden layer and vanishes.
- Layer commands keep guides with their layers: Duplicate (and Alt-drag
copies of rows) copy a layer's guides onto the copy, Merge Selected and
Flatten Artwork put the merged layers' guides on the merged layer and
Flatten discards a hidden layer's. One shared doc predicate for "the
layer a guide is on"; the deleted-layer cleanup moves into the doc.
- Tests: engine tests for the above and their undo, a doc check that an
old guide reads with no layer and none is written, and a headless
Layers panel test of guide rows (select, drag to another layer, locked).
- Translations for the new message; docs and a ROADMAP progress line.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Guide rows in the Layers panel drag (the selected guides when the row is
one of them) onto any row of another layer, which lights up, and go to
that layer in one undo step (guide.setLayer takes indexes). Guides on no
layer, from files saved before guides had layers, go on the top visible,
unlocked layer when a document opens or is reverted.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
A ruler guide goes on the current layer (guide.add {layer}), shows,
hides, locks and is deleted with its layer, is listed in the Layers panel
as a row that selects it, and moves to another layer (guide.setLayer).
Guides on a hidden layer neither show nor snap; those on a locked layer
can't be picked.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
A Shift-click on a guide adds it to the selected art (guide.select
toggle keeps the art), and Shift-adding or -removing art keeps the
selected guides; a new selection still clears them. With both selected,
Delete deletes the art and the guides in one undo step (not when ids are
given).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
- A visibility variable could hide an object but never show it again: dataset
application, capture and binding refused any object that wasn't visible
(`is_editable`), so a row that hid an object left it hidden for good and a
capture after hiding it in the Layers panel didn't record it. They now use
`Document::variable_target`, which ignores the object's own visibility
(locked art and art on hidden or locked layers are still skipped).
- Renaming a variable renamed the active dataset when the two shared a name,
leaving it pointing at a dataset that doesn't exist.
- `variable.delete` and `dataset.delete` without `names` act on the
highlighted variable and the active dataset, so Window > Variables > Delete
Variable / Delete Data Set no longer fail with "missing `names`".
- `ids` is checked like other commands' explicit targets (a non-list no longer
falls back to the selection, bad ids are errors), names are trimmed and
capped at 255 characters, and the bind checks are shared by Bind and Make
Dynamic.
- Make Dynamic's fallback name no longer becomes " 2" for unnamed art.
- `dataset.list` reads the art once for every row's drift check, and
`Variables::prune` looks names up in a set.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The engine side was called a solid base, but the panel could only list and
apply: variables and datasets were made through commands, which most people
won't find. The reference app's Variables workflow is now what the panel does,
and every button runs a `variable.*` / `dataset.*` command, so the panel and an
agent drive the document the same way.
New commands (all also in Window › Variables, the palette and over MCP):
- `variable.makeTextDynamic` / `variable.makeVisibilityDynamic`: define a
variable of that kind over the selection, named after what it drives, and
bind it — one step, as the panel's two buttons.
- `dataset.capture`: record what the bound objects hold right now as a new row
("Data Set 1"… ) and make it active. This, rather than typing values, is how
rows are made: change the art, then capture.
- `dataset.update`: write what the art holds now into the active row. Without
an active row it says so rather than picking one.
- `variable.rename` / `dataset.rename` (Variable Options…, Rename Data Set…):
a name is how a variable is referred to, so renaming carries its bindings and
its values in every row with it.
- `dataset.list` reports `matches` per row: whether the art still holds what
that row says.
`Document::captured_values` is the one place that reads a bound object's value,
so capture, update and the drift check can't disagree about what the art says.
Panel: the current data set across the top as a dropdown with ◀ ▶; variable
rows with their kind's icon and the name of the object they drive ("Multiple
objects" when it is more than one), a click selecting it and a double click
opening Variable Options; a dataset row in italics once an edit has moved the
art away from what it says; Make Text/Visibility Dynamic, New, Unbind and Delete
below, Capture and Update on a second line; and a (≡) menu with the same
commands for the ones that act on a row rather than the selection.
Checked on Rust 1.99 (the current stable), which is what the review asked for:
cargo xtask ci, all 7 steps pass.