Follow-ups to the review of #4697 and #4698:
- With textAs: outlines, a .ai saved without PDF compatibility, read
from its editing data alone, has its type outlined too, as an empty
page or a text-only placeholder already did.
- Threaded type read from the editing data holds its story in its first
frame: it now flows through its frames before it is outlined, so each
frame's outlines show the text it holds, and the outlined frames
leave their threads.
- Each outline group keeps its type's opacity, blend mode, isolation,
opacity mask and effects (the same carry-over the flattener uses).
- A gradient-mesh corner (/N) holding anything but numbers is unread,
instead of being read with the numbers after it shifted into its
colour and opacity.
A page cropped to the art is recognised in a UserUnit page's units too,
when the art's box is the page in those units and the artboard isn't the
page in points. Such pages are taken only when every page is the art's
box, and only the first page's type is read: the later pages are copies
whose type was moved by the first page's offset and could fill the
layers' text objects twice.
prune_unseen ran before the retry that moves placed files to their box's
corner, and that retry cloned the pruned layers: text an image covered
only at its first placement was already gone, so the file came in without
it or as its PDF part. Clone the layers before pruning and prune each
placement on its own.
Character attribute 12 of the editing data's text document is the case
(1 Small Caps, 2 All Caps). The reader skipped it, so type set in All Caps
came in as typed, in lower or mixed case where the page draws capitals.
Map it onto CharStyle::all_caps and CharStyle::small_caps.
A mesh in the editing data that the reader couldn't read made the whole
file come in as its page or PDF part, without its layers. Three cases
were rejected that files write routinely:
- A mesh starts grey, and `/CS` is written only where the colour space
changes, before the corner it applies to, even within a patch. Meshes
with no `/CS`, or one that changed between corners, were unread.
- Named colours (`[c m y k /DeviceCMYK (name) /Process]` or `/Spot`) and
`/NChannel` inks: each corner gives the tint, 1 being the full colour.
- Grids over 64 patches a side or 4,096 patches (files have 97 x 72).
Each corner now takes the colour space last set before it; named
colours paint at their tint and inks overprint at theirs; the limits
are 128 a side and 16,384 patches.
A placed (linked) file is put where its matrix takes the canvas point of
art space's origin (the centre of %AI3_TemplateBox). In many files the
matrix takes the corner of the box the file is written with instead, so
the image landed far off its artboard, the layers differed from the
page, and the file came in as its PDF part.
The reader keeps that second placement for each placed file where the
two differ. When the layers don't draw as the page, they are compared
again with those files at their box's corner, and used if that draws
as the page. Files whose layers already match keep their placement.
A placed (linked) file written as an object's editable copy, on %_
lines after its drawn look, has its %AI5_BeginPlace and %AI5_EndPlace
markers on those lines too. The lexer dropped comments after %_ that
don't start a section, so the placed file never began, the ~ that ends
it was an operator the reader didn't know, and the whole file came in
as its PDF part. The lexer now returns those two markers from %_ lines,
and the preview reader skips the %_ its header line starts with.
A PDF-compatible .ai whose PDF part's pages are cropped to its art (each
page the box of all the art, the editing data's bounding box) rather
than to its artboards came in as that PDF part: the editing data's
artboard wasn't the page's size, so its layers were refused. With
several artboards every page held all the art, so the document had a
copy of everything per page.
When the artboard isn't the page's size but the editing data's bounding
box is, the layers are compared with the page on that box and keep
their artboards. The comparison of their art with the page still
decides whether they are used.
A layered .ai or Illustrator EPS whose editing data (embedded rasters,
mostly) unpacks past 512 MiB opened as its PDF part or printed page, in
one layer, with "it decompresses to more than 512 MB" (#4628 on 0.17.0
hit the older 256 MiB limit the same way).
The limit is now an eighth of installed memory, from 512 MiB (what every
64-bit build read before) to 2 GiB: 1 GiB on an 8 GiB machine, 2 GiB on
16 GiB or more. Reading takes about as much memory again as the data
(1.1 GB of data with 24 embedded 4096 x 4096 images peaked 1.1 GB above
the start), so the ceiling keeps a small file that unpacks without end
from taking a large machine's memory. 32-bit and web builds keep 256 MiB.
vectorcraft_render::installed_memory exposes the reading the image cache
already sizes itself from.
When the editing data's art is rejected (another page size, art the page
doesn't draw, too many text objects…), the file still comes in as its page,
but now on a layer above the editing data's layers. They come in empty, in
their order, with their names, visibility, lock, print option and sublayers.
The page's layer takes a free name when the file's layers have its Layer 1.
A file's PDF part draws a right-to-left paragraph's words in the order
they show ("mm 120.5"), while its text object keeps them in the order
they are typed (" 120.5 mm "). The page's type was matched to the text
objects by their letters in order, so it matched none and stayed beside
the label as a second copy. It is now matched word by word.
A point type label turned a quarter (a vertical dimension's) lay outside
the box its text object was looked for in, which was always level; that
box now runs along the type's baseline.
The text document's paragraph key 33 (from the paragraph or the
document's defaults) said a paragraph runs right to left, but the reader
only used it to swap left and right alignment: the paragraph's direction
stayed unset, so it went by its first strong letter, and a label with no
Arabic or Hebrew letters (" 120.5 mm ") laid out left to right instead of
reading "mm 120.5". The paragraph style now gets that direction.
Versioned content can hold three alternatives: newer files write a
Dimension tool group, the same group again after
`%AI17_Alternate_Content_if_version_gt:…` for a version in between, then
plain art after `%AI17_Alternate_Content`. The reader skipped from the
plain `Alternate_Content` marker only, so the middle alternative was read
as art too and those objects came in twice.
The reader now reads the first alternative and skips from the next one,
whatever its marker, to the end of the block, nested blocks included. A
block that doesn't end is read whole with a note instead of failing the
file. The palette reader tracked alternatives with one flag that a block
nested in an alternative reset; it now tracks the depth.
A .ai swatch library without layers comes in as its PDF part with the
swatches of its editing data's palette. A pattern swatch was kept only
when the PDF part defined its pattern, and a pattern library's PDF part
usually defines none, so its pattern swatches were dropped.
ai::palette now gives pattern swatches for the patterns the editing
data defines too, and returns the definitions the PDF part lacks that a
palette swatch paints with, with the images their art shows. The
document gets them with new object ids.
A .ai file whose editing data has no layers, as swatch libraries are
saved, opens from its PDF part, and the PDF part has the default
swatches. layered_ai applied the editing data's palette only when it
built the document from the layers, so Other Library… listed the
default swatches for such a library, and File › Open gave the file the
default swatches as well. A .ai whose layers can't be used (an image
too large for the editing data's reader, or a read error) lost its
palette the same way, and so did an EPS that opens as its page.
The reader now reads the palette on its own (ai::palette, which shares
palette_swatches with the layer reader), and its swatches and color
groups replace the default swatches in those three cases. Spot and
pattern swatches of the PDF part's art that the palette does not have
follow the palette's swatches.
Fixes#4100. Part of #3349.
Found while testing crates/eps/src/import/lex.rs:287-289 (number)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Found while testing crates/eps/src/import/text.rs:501-502 (glyph_char)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The EPS error-meaning test walks src/import and its subdirectories. The
picks and tl! literal scans skip directories named tests, as the undo and
message scans already do, so test modules moved to foo/tests.rs or
tests/<topic>.rs are not read as interface code.
## What and why
Hostile `.ai` art styles and gradients (out-of-range caps, joins, miter limits, dash arrays, stroke widths, width profiles, blend opacity, paint flags, operand counts, too many gradient stops) must be clamped or ignored on import. The tests only: the limits and checks they cover are already on main, so the tests pin that behaviour so a later change can't silently drop it.
## How it was tested
The new tests pass, along with the full `cargo xtask ci` on the branch.
Combines the tests from: stream/vc-featD-g8-art-style-blend-opacity, stream/vc-featD-g8-art-style-cap-join, stream/vc-featD-g8-art-style-miter-cap, stream/vc-featD-g8-art-style-operands-cap, stream/vc-featD-g8-art-style-paint-flags, stream/vc-featD-g8-art-style-stroke-width, stream/vc-featD-g8-gradient-stops-cap, stream/vc-featD-g8-art-style-dash-cap, stream/vc-featD-g8-art-style-width-profile-cap
## What and why
Hostile EPS programs that give an operator the wrong operand type (a non-integer count, a non-procedure body, a non-boolean condition) must stop the import with a `typecheck` error instead of misbehaving. One test per operator: copy, for, idiv, if, ifelse, index, loop, mod, repeat, roll and srand. The tests only: the limits and checks they cover are already on main, so the tests pin that behaviour so a later change can't silently drop it.
## How it was tested
The new tests pass, along with the full `cargo xtask ci` on the branch.
Combines the tests from: stream/vc-featD-g8-eps-copy-typecheck-count, stream/vc-featD-g8-eps-for-typecheck-procedure, stream/vc-featD-g8-eps-idiv-typecheck-operand, stream/vc-featD-g8-eps-if-typecheck-condition, stream/vc-featD-g8-eps-if-typecheck-procedure, stream/vc-featD-g8-eps-ifelse-typecheck-condition, stream/vc-featD-g8-eps-ifelse-typecheck-procedure, stream/vc-featD-g8-eps-index-typecheck-count, stream/vc-featD-g8-eps-loop-typecheck-procedure, stream/vc-featD-g8-eps-mod-typecheck-operand, stream/vc-featD-g8-eps-repeat-typecheck-count, stream/vc-featD-g8-eps-repeat-typecheck-procedure, stream/vc-featD-g8-eps-roll-typecheck-j, stream/vc-featD-g8-eps-roll-typecheck-n, stream/vc-featD-g8-eps-srand-typecheck-operand
Review follow-ups to the live blend import (#4127):
- The check's grid saturates its neighbours' keys: a step far out (a
coordinate like 1e300) saturates its cell, and the cells round it
overflowed (a panic in debug builds, a wrong cell in release).
- Each laid object matches one drawn step: two drawn steps near one laid
object no longer both take it, so a drawing the blend doesn't lay out
isn't taken for it (the drawn steps stay).
- A blend's record is read to the length its XP gives: hex digits after
it on the last line no longer spoil it.
Tests: a laid object matches one drawn step, 1e300 coordinates in the
check and in the import fuzz property, and a record line longer than
the record.
Review follow-ups for the live blend import:
- The check of a blend against its drawn steps matched every drawn step
against every laid one and measured centres inside the loop, so a
crafted blend (64 keys, 1,000 steps between each) could stall the
import. Centres and fills are now measured once, and each drawn step
finds its neighbours through a grid of cells the tolerance wide.
- A blend lays out at most 5,000 objects to be made live, and all the
blends of one import at most 50,000 (each laid out twice, once per
orientation); past either, the drawn steps stay, with the note. The
live steps take the place of drawn steps already counted against the
reader's object limit.
- A fill the check can't compare (a gradient, a pattern) or a missing
one no longer passes: the fills must both be none or solid colours
within the tolerance.
- When what follows the blend's own art isn't a group of drawn steps,
that art (its keys and spine) goes too, with the note, instead of
staying drawn.
- The spine test can no longer pass without asserting: a spine written
backwards keeps the keys' order (live), and a curved spine is read
exactly while its straight-drawn steps stay drawn. New tests for steps
with a gradient, for the blend's art when its steps aren't a group,
and for the budget; import_fuzz gains a mutated-blend property and a
large-blend test (998 steps live, 1,500 kept drawn, both quickly).
A blend in a .ai file's editing data came in as plain art twice over:
the blend's own art (its key objects and spine, on `%_` lines) and its
drawn steps were both kept visible, so the keys lay doubled under the
drawn ones and the spine showed as a stray path, and nothing was live.
The spine itself was read exactly (its line and curve segments match).
The `(Adobe Path Blends) 1 0 n XP` record is now read (n bytes of hex on
the `%` lines after it, capped, checked: `DCBA`, version 1, the key
count and a record per key starting with its id). Each object's
dictionary names its part (`ShapeBlendPathType` K or S) and a key's id
(`ShapeBlendPathID`); the keys the record lists and the spine become one
live blend, the keys sitting on the spine's anchors. The record's other
fields aren't known, so the spacing is the drawn steps between two keys
(Specified Steps) and the orientation whichever lays the blend out as
drawn; the blend replaces the drawn steps only when it comes out as
drawn (each step within 0.5 pt and 0.02 of its colour). Otherwise the
drawn steps stay, without the blend's own art over them, with a note.
Behaviour as documented: https://helpx.adobe.com/illustrator/using/blending-objects.html
Save As .ai writes the PDF-compatible part only (no editing data), so a
blend is written back as its steps drawn, not live.
Hand-written tests: two keys and a straight spine as one live blend with
three steps and interpolated colours; a curved spine (l, c, L) in both
directions keeping its points and the keys' order; a garbled or short
record, a key without an id and ids the record doesn't list keeping the
drawn steps with a note; moving a key moving the steps; record parsing
on hostile input. The import fuzz target gains the record and the new
mesh keys.
An undefined number (--) in `-- -- angle length Bh` is read as a radial
gradient with no highlight, and XC (a color space name after a linked
image's file name) is ignored like its neighbours. Either one used to make
the whole editing data unreadable, so the file opened as its PDF part and
lost its layers and live text. A -- anywhere else is still an operator the
reader doesn't know.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Audit: EPS interpreter and import bounds
* Audit: EPS writer stops expanding at 256 MB of body
* fix(eps): enforce MAX_BODY budget before committing node and image output
* chore(text): use is_some_and instead of unnecessary map_or (clippy)
* Fix clippy unnecessary_map_or false-default in ITXT compression flag scan
* Reject EPSI previews with an odd number of hex digits
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
The editing data's palette (%AI5_BeginPalette) was skipped, so a .ai file
opened with the default swatches plus only the spot colours its art used:
inks the art doesn't use (a Pantone set kept in the file) and colour groups
were lost. The palette is now read in order (process, spot in CMYK, RGB or
Lab, and global colours, gradients and patterns, colour groups; versioned
content read once) and replaces the default swatches.
Xs/Xz (Registration at a tint, as printer's marks use it) are read as the
Registration swatch: before, art painted with them stopped the editing data
from being read at all.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
search_in_a_loop_runs_out_of_the_budget searches about 3 GB before the operation budget runs
out: 14 s on a fast desktop, over the 20 s limit on the FreeBSD runner. The 20 s limit stays
for the tests that use it as a speed bound.
The editing data writes a gradient mesh as its patches, each with its
place in the grid (`/P`) and the grid's size (`/Size`); the importer
read each patch as a mesh of its own, in a group, so moving a mesh
point tore a patch away and left a hole, and a patch with anchors on
its edges was cut into cells on its own.
The patches are now placed by `/P` in the `/Size` grid and joined at
the corners they share into one GradientMesh. Which index of `/P` runs
along the columns is taken from the one under which the shared corners
meet. Where an edge bends through anchors, a mesh line runs through the
whole row or column of patches there, each patch cut at the same place,
so every cell keeps single-cubic edges. A mesh whose patches don't say
where they are reads as before; one whose patches fill the grid but
don't join up reads as before with a warning. Sizes and lines are
capped; non-finite values fall back.
Hand-written tests: a 2 x 2 grid as one mesh with the old patches'
colours, a moved inner point shared by its four cells, an anchor on a
shared edge adding a line through the mesh, a collapsed patch with
finite values, and unplaced or unjoined patches staying separate.
VectorCraft had no lists. A bullet could only be typed as a character,
and the tab after it did not stop at the left indent. The reference app
turns paragraphs into a bulleted list from the Paragraph panel, the
context menu and Type › Bullets and Numbering › Apply Bullets.
text.setFormat takes list ("none" or "bullet") and bullet ("none",
"dot", "dash", "circle" or "square"). The layout draws the bullet before
the first line of each list paragraph that has text, in the font, size
and color of its first character, and starts the text at the next tab
stop, with the left indent counted as a stop. Wrapped lines start at
the left indent, and type on a path draws the bullet too. In a
horizontal right-to-left list paragraph the indents are mirrored: the
bullet hangs at the right, and the wrapped lines start at the right
where the first line's text starts after the bullet. The bullet is not
a character of the text. A paragraph whose left and first-line indents
are both 0 gets 18 pt and -18 pt when it joins a list. Return continues
the list, paragraph styles carry it, Reset Panel turns it off, and the
native file saves it only when it is set.
The Paragraph panel has a Bullets button with a list of the five
presets, and Type › Bullets and Numbering › Apply Bullets and the
context menu run type.applyBullets. Live SVG text writes the bullet's
character, and SVG writes type on a path with bullets as outlines. DXF
writes the bullet as a TEXT entity of its own, and legacy .ai export
warns that bulleted lists are left out. The caret, selections, Touch
Type and Show Hidden Characters skip the bullet.
Live SVG text of horizontally scaled type now writes each tab in a
tspan of its own. Before, the characters before a tab shared its tspan,
and its textLength made a viewer stretch them across the tab's gap.
Docs: docs/mcp.md, docs/target-app-parity.md.