Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 7255ac394d Fix #745: a working Libraries panel of graphics, colours and text styles
The Libraries panel was a placeholder: it invited dragging art onto it,
but nothing happened. It now keeps local libraries (no account, no
cloud), as Illustrator's Libraries panel does:

- drag art off the canvas (or a swatch) onto the panel to add it, or use
  + to add the selection's graphic, fill or stroke colour, character
  style or paragraph style;
- drag a graphic onto the canvas (or double-click it) to place a copy,
  click a colour to paint the selection through the active Fill/Stroke
  proxy, click a style to apply it to the selected type;
- the library picker and the panel menu create, rename and delete
  libraries; each is a .vclibrary file in a Libraries folder next to the
  preferences (session-only in the web app, headless sessions and runs
  without preferences).

Engine: library.list/get/setCurrent/create/rename/delete/add/use/
removeItem. A graphic is stored as a native document with a thumbnail:
adding copies as Copy does and placing pastes as Paste does, resources
and swatch-name conflicts included. Using a style adds it to the
document and applies it in one undo step.

Clipboard::to_document gave its layer the id u64::MAX, which made a
saved copy overflow Document::fix_next_id when read back; the layer now
takes an id above the objects', and fix_next_id saturates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:12:59 +07:00
BFlat c2711df30b Merge pull request #751 from dmatviichuk/i18n-ukrainian
Add Ukrainian (uk) interface translation
2026-10-09 19:29:17 +07:00
bflatasticandClaude Opus 5.5 f86618777e i18n: Ukrainian for the tab bar's » tooltip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:19:03 +07:00
bflatastic 14d8efcabf Merge branch 'pr751' into land/751 2026-10-09 19:17:34 +07:00
Dmytro Matviichuk 8a19490ea0 i18n: add Ukrainian interface translation (#747) 2026-10-09 14:15:12 +02:00
BFlat 0e6ca8f88d Merge pull request #749 from storytold/fix/746-tab-overflow
Fix #746: document tabs that don't fit are reached from a » list
2026-10-09 19:10:56 +07:00
bflatasticandClaude Opus 5.5 e8255f2bd5 Fix #746: document tabs that don't fit are reached from a » list
With more documents open than the tab strip holds, the tabs past its
right edge were cut off with no way to reach them. As in Illustrator, a
» button at the strip's right end now lists every open document (the
active one checked) and picks one, and the strip always shows the active
tab, starting later when it has to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:06:40 +07:00
BFlat dd1a32bb8e Merge pull request #744 from jmverges/fix-guides-export
Fix #743: raster export leaves guides out
2026-10-09 18:51:21 +07:00
BFlat 257c8ec435 Merge pull request #742 from jmverges/pdf-import-slanted-type
PDF import: slanted type keeps its slant
2026-10-09 18:40:19 +07:00
Juan Manuel Verges SolanasandClaude Opus 5.5 ad8458832d Fix #743: raster export leaves guides out
skip_templates, which exports and thumbnails set, left template layers out
but drew guide paths as hairlines. It now skips guide paths too, as the
SVG, PSD and PDF exports and printing already do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:31:29 +00:00
BFlat a01b0dfc7a Merge pull request #739 from AutoHenry/feat/602-scale-art-with-artboard
Fix #602: Scale Artwork with Artboard
2026-10-09 18:29:28 +07:00
Juan Manuel Verges SolanasandClaude Opus 5.5 ddd1c8fade PDF import: slanted type keeps its slant
A text matrix that shears the glyphs (type turned and slanted as a whole)
came in as point type turned by the baseline's angle only: the glyphs stood
square on the baseline, so the type drew somewhere else (60 pt type leaning
by 0.25 came back about 10 pt off its outlines). The placement now keeps the
slant (the em's lean along the baseline per unit of its height, the height
measured across the baseline), lines only join glyphs with the same slant,
and the type object's transform shears by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:18:03 +00:00
BFlat eefe849e14 Merge pull request #741 from storytold/fix/733-pdf-live-text
Fix #733: PDF export keeps type as text in embedded subset fonts
2026-10-09 18:16:34 +07:00
bflatasticandClaude Opus 5.5 00344f9b68 Fix #733: PDF export keeps type as text in embedded subset fonts
The PDF writer defaulted to Convert Text to Outlines, so every glyph of
every page became a path: a text-heavy PDF grew several times larger on
export (a 7-page document went from 27 KB to 226 KB) and its text could
no longer be selected or searched. Illustrator's PDF presets embed font
subsets and keep live text; that is now the default too
(advanced.outlineText: false). Outlining stays available as an option,
fonts that can't be embedded still become outlines with a warning, and
printer's marks (page information) are drawn as shapes as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:06:38 +07:00
Hal GoffandClaude Opus 5.5 b8a61e90ce Add my contributor name (AutoHenry)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:50:35 +01:00
HTandClaude Opus 5.5 f2b35fc039 Fix #602: Scale Artwork with Artboard scales the art with a resized artboard
`artboard.setProps {scaleArt: true}` maps the art fully inside a resized
artboard (as Move Artwork with Artboard gathers it) and its artboard guides
from the old rectangle onto the new one, each side by its own ratio, in one
undo step, and returns the ids scaled. Strokes, effects and corners follow
Scale Strokes & Effects and Scale Corners (`strokes`, `corners`), pattern tiles
Transform Pattern Tiles (`patterns`); locked and hidden art follows
`lockedAndHidden` (default: prefs moveLockedWithArtboard). The resolved choices
are journaled, so a replay scales the same objects the same way. A move that
keeps the size scales nothing.

The Artboard tool's `scaleArt` option (saved with `moveArt`) makes handle
drags proportional, as the public help describes, keeps them proportional at
the 1 pt minimum, and leaves the scaled art out of the Smart Guide targets.
Move and Scale Artwork with Artboard are check boxes in the Control bar and
Properties' Edit Artboards; the artboard size fields there and in the
Transform panel pass the option. Translated in the eight catalogs that have
Move Artwork with Artboard. docs/mcp.md and the ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:50:35 +01:00
BFlat 133ad87541 Merge pull request #737 from storytold/fix/712-hide-box-dragging
Fix #712: the bounding box hides while a drag moves, scales or rotates
2026-10-09 17:46:53 +07:00
bflatasticandClaude Opus 5.5 f8a7695c3a Fix #712: the bounding box hides while a drag moves, scales or rotates
During a Selection-tool drag the bounding box and its handles stayed on
screen while the art moved, so the content looked as if it slid inside
a box of its own. The box now hides once a drag moves, scales or
rotates the selection, as in Illustrator, and comes back on release. A
press that hasn't moved, and a marquee, keep it. Tools gain
`transforming()` (Session::tool_transforming) for this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:39:12 +07:00
BFlat 66cb7848f3 Merge pull request #736 from storytold/feat/724-bold-italic
Fix #724: Type › Bold and Italic, Cmd+Shift+B / Cmd+Shift+I while typing
2026-10-09 17:26:35 +07:00
bflatasticandClaude Opus 5.5 ee16d10fd4 Fix #724: Type > Bold and Italic, Cmd+Shift+B / Cmd+Shift+I while typing
Designers coming from page layout apps expect Cmd+Shift+B to bold the
text they are editing. Type > Bold and Type > Italic switch the selected
type (or the Type tool's selection) to its family's own Bold or Italic
face, and back to the regular weight or upright face when it already is,
keeping the face's other words (a Narrow face stays Narrow). There is no
fake bold or slant: a family without the face is left as it is and the
status bar says so. While the Type tool edits text, Cmd+Shift+B and
Cmd+Shift+I apply them; elsewhere Cmd+Shift+B still hides the bounding
box. Both can be given other shortcuts in Edit > Keyboard Shortcuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:20:58 +07:00
BFlat 82af7a3108 Merge pull request #730 from storytold/feat/637-ai-without-pdf
Part of #637: .ai files saved without PDF compatibility open from their editing data
2026-10-09 16:50:03 +07:00
BFlat 46e4262376 Merge pull request #731 from storytold/fix/729-tabs-above-text
Fix #729: the Tabs panel's Above Text puts its ruler over the text
2026-10-09 16:41:55 +07:00
bflatasticandClaude Opus 5.5 c16804237e Fix #729: the Tabs panel's Above Text puts its ruler over the text
The Tabs panel's ruler was a fixed strip in the panel, at no relation to
the text on the canvas, so stops couldn't be placed by eye. Above Text
(Position Panel Above Text) floats the panel just above the selected
text, as wide as its ruler needs to put 0 on the text's left edge and
its marks at the canvas zoom: each stop sits over the column it sets.
Floating panel groups keep a width they are given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:38:49 +07:00
bflatasticandClaude Opus 5.5 4b9f9b9bc2 Part of #637: .ai files saved without PDF compatibility open from their editing data
Such a file's PDF part is a placeholder page, which was refused. When
the file carries editing data, it now opens from that alone: its
structure as a PDF-compatible file's, its point type made from the text
document, and type it can't make (area type, type on a path) left out
with a note that marks the import as lossy. Without editing data, or
with pages picked or layers off, the placeholder is refused as before;
damaged editing data says so.

The page-order note is no longer given when the page has no type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:35:22 +07:00
BFlat 008b5e6d6c Merge pull request #727 from storytold/feat/637-structure-reader
Part of #637: Illustrator editing data read whole: colours, gradients, transparency, names, images, artboards
2026-10-09 16:26:57 +07:00
BFlat 640a46a76f Merge pull request #728 from storytold/fix/725-marquee
Fix #725: the selection marquee shows on the pasteboard too
2026-10-09 16:19:10 +07:00
bflatasticandClaude Opus 5.5 9b4166ed5e Part of #637: the editing data's readers stay linear on hostile input
Zstandard frames: the trailing zero padding is trimmed once, not looked
for again before each frame, and at most 4096 frames are read (more is
damaged data). A `]` finds its `[` from a list of where they are rather
than by scanning the operand stack back. Images are read up to 32
million pixels. Tests: thousands of tiny frames, and hundreds of
thousands of unmatched brackets, read in one pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:18:23 +07:00
bflatasticandClaude Opus 5.5 2d12b39261 Palette test: look twice until shortcuts and plug-ins stay put
The cached entries' key includes two global counters that tests running
alongside bump (shortcut edits, plug-in installs), so the 'kept while the
language stays' check failed now and then. It now compares two lookups
only when the key didn't change between them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:15:09 +07:00
bflatasticandClaude Opus 5.5 d53074487c Fix #725: the selection marquee shows on the pasteboard too
It was grey dashes only, which vanish on the grey pasteboard. It is now
dark dashes over a light line, so it reads on white artboards and on the
pasteboard alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:06:40 +07:00
bflatasticandClaude Opus 5.5 0be7640088 Part of #637: the editing data's structure read whole, colours, gradients, transparency, names, images and artboards included
The layers reader of the editing copy (operators translated into
PostScript for the interpreter, any file with RGB colours, gradients,
transparency or images falling back to its page) is replaced by a reader
of the whole structure (import/ai): layers and sublayers with their
options, groups, compound paths, clipping groups and layer clipping,
names, hidden and locked objects, RGB, CMYK, grey, named and spot
colours, gradients, transparency, fill rule, overprint, embedded images
with their alpha channel, guides and every artboard, and objects drawn
as their look when they have several fills or strokes, effects or a
brush. Containers: Zstandard, zlib or plain data in EPS and .ai.

The text placement, the comparison with the page and the overwrite guard
stay around it. The page's type takes its text object's place without a
group; the comparison is by lightness, on nothing, without layers that
don't print. Symbols, pattern fills, placed files and unknown operators
make the file come in as its page where they show, and are left out of
layers that don't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:02:36 +07:00
BFlat 6e2cd95532 Merge pull request #723 from imachus/pr/ja-paragraph-start-bracket
Type: an opening bracket at a paragraph's start is set flush too, JIS X 4051's principle (#633)
2026-10-09 15:52:28 +07:00
bflatastic 4f515d1b21 Merge remote-tracking branch 'origin/main' into land/723
# Conflicts:
#	ROADMAP.md
2026-10-09 15:52:10 +07:00
BFlat 9f7526aee6 Merge pull request #721 from imachus/pr/ja-kinsoku-set
Type: Kinsoku Set — Hard (today's set), Soft and None (#633)
2026-10-09 15:51:18 +07:00
BFlat 5e0efa7b1e Merge pull request #719 from jax-spenser/feat/image-trace-flat-logo-mode
Image Trace: Flat Logo mode (sub-pixel edges, restored corners, true lines and circles)
2026-10-09 15:43:17 +07:00
imachusandClaude Opus 5.5 aeaa2be10b Type: an opening bracket at a paragraph's start is set flush too, JIS X 4051's principle (#633)
With Line-end Punctuation Half Width, an opening bracket that starts a paragraph now drops the
half-em space before it, as one starting a wrapped line already does (#417): its face starts at
the first-line indent, JLREQ 3.1.5's Figure 71 ① (the principle JIS X 4051 adopts), as decided in
#633. Paragraph-start brackets in documents set with this mojikumi move half an em toward the line
start.

Two mojikumi tests used a bracket at a paragraph's start to test something else (consecutive
brackets, the Japanese–Latin space); they now set it inside the line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:36:30 +09:00
BFlat 0aac6f1d75 Merge pull request #720 from callmeBalloch/claude/kind-darwin-btjse2
Affinity README: say which sample set the semantics were fitted on
2026-10-09 15:32:48 +07:00
bflatastic 5db4d9b78b Merge remote-tracking branch 'origin/main' into land/719 2026-10-09 15:32:23 +07:00
bflatasticandClaude Opus 5.5 ffc0205613 i18n: Flat Logo in Japanese and Chinese
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:31:24 +07:00
imachusandClaude Opus 5.5 806e543fd1 Type: Kinsoku Set — Hard (today's set), Soft and None (#633)
Paragraph panel › Kinsoku Set (with the East Asian options) and `text.setFormat {kinsoku}`, as
decided in #633:

- Hard: today's set, so existing documents don't change; the default, and not saved.
- Soft: as Hard, except that 々, the prolonged sound mark ー (JLREQ cl-10) and small kana (cl-11)
  may start a line: JLREQ's level 3 line-breaking rules (Appendix C.3). Half-width forms aren't in
  those classes and stay off the line start.
- None: no kinsoku.

The setting reaches every break the composers consider (greedy, every-line and hyphenation
points). Reset Panel returns to Hard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:26:37 +09:00
BFlat be47c41b47 Merge pull request #715 from DisasterAreaDesigns/eps-editing-data-pr
Read Illustrator EPS and .ai layers from their editing data
2026-10-09 15:25:59 +07:00
bflatastic 58b95b51ef Merge branch 'pr719' into land/719 2026-10-09 15:19:04 +07:00
bflatasticandClaude Opus 5.5 26b6893064 EPS import: clippy's simpler form of the off-page test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:06:39 +07:00
bflatasticandClaude Opus 5.5 0559aac53e Save and Export ask before replacing a file whose import left things out
The engine refuses to write over the file a document was read from when
reading it left something out, unless acknowledgeLoss is passed. In the
app that surfaced as an error naming a parameter. File > Save, Save As,
Save a Copy, Save as Template and Export As now ask instead ("Replace
'a.eps', the file this document was opened from?", with what would be
lost), and OK repeats the command with acknowledgeLoss. Closing a
document whose save asks keeps it open, and so does Package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:05:52 +07:00
bflatasticandClaude Opus 5.5 5f4f82d885 i18n: French says calque for a layer in the new import messages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:05:11 +07:00
bflatasticandClaude Opus 5.5 5c040e5095 EPS import: cap the Zstandard window a hostile frame can ask for
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:05:11 +07:00
callmeBallochandClaude Opus 5.5 cdea4ff593 AF.3: say which sample set the Affinity semantics were fitted on
The README said 176 public documents in its provenance and 189 in its
limits. The thumbnail fitting was done on 176; the collection has since
grown to 189 distinct documents, and the reader opens all of them (298
files with duplicates, no failures). State both. PhotoCraft's copy of
the reader got the same fix in storytold/photocraft#1607.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
2026-10-09 08:02:33 +00:00
bflatastic 53c4c6eb02 Merge branch 'pr715' into land/715 2026-10-09 14:57:46 +07:00
BFlat 24c77e9ddf Merge pull request #713 from storybored379/feat/mcp-conventions
MCP: common command tools, strict arguments and session resources
2026-10-09 14:56:41 +07:00
BFlat e4a7c64ccb Merge pull request #714 from storytold/fix/546-dxf-fill-outlines
Fix #546: DXF export writes each fill's outlines before its hatch
2026-10-09 14:54:06 +07:00
Matthew FarrowandClaude Sonnet 5.5 8fe0fd8394 EPS import: bound what hostile editing data can cost, and say when it can't be read
- a story is read once however many text objects name it; the text objects, their pairs with the
  page's type, the pieces of a line and a story's size are capped, and over a cap the layers aren't used
- the ASCII85 of the text document is bounded before it is decoded
- joining the pieces of lines that interleave no longer removes the wrong pieces
- a run length too large for the text saturates instead of overflowing
- a dictionary left open at the end of a layer, and layers nested deeper than are read, make the
  file one that comes in as its page (they were silently dropped)
- a file whose editing data can't be decoded says so, and the notes about art that was too much or
  too far away count as losses, so writing over the source asks first
- the private data of a .ai is inflated under a cap of its own (the PDF reader's decoder has none)
- the other writers (PDF, selection, office, print) ask before replacing a source that lost things,
  and a recovered copy remembers that

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 e622b87ca2 EPS import: don't claim a specification as the source of the tests or the wrappers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 365d6a7023 i18n: French for the new import messages
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 964985b68c EPS import: adapt text runs to inline art, refresh Cargo.lock
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 319e60125f EPS import: say plainly that no Adobe specification was used
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 2f0ede9c44 EPS import: leave out text objects that the page doesn't draw
Type turned to outlines leaves its text object in the file, which the app that
wrote it doesn't draw (it paints every shown text object on the page). A text
object made from the file's text document that shows on the page's area where the
page has no type, and without which the layers look no worse against the page, is
left out, with a note.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 94dfa20fa0 EPS import: joined text lays out its bounds afresh
The pieces joined into one text kept the layout cached for the first piece, so the selection box covered only that piece.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 2dc738b816 EPS import: spell out abbreviated styles, and keep a line the page draws in pieces as one text
- A PostScript font name's shortened style words (Md, Lt, Bd, XBd, Cn, It, ...)
  weren't matched to installed faces, so AkzidenzGroteskPro-Md came in as
  the condensed face. They are spelled out before the face is looked up.
- A line the page draws in pieces (around kerning) came in as several text
  objects ("swe", "ep"); the pieces that add up to one line of the file's
  text object now join into one, with the line's spaces between them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 618b1cf94f EPS import: a commented copy of a text object that has a plain one isn't another object
Some files write a text object twice, once plainly and once as comments (and
others only as comments). Reading both made a second copy of the type, built from
the text document, over the page's own (wrong size and place). The commented copy
is now skipped when its story has a plain text object.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
Matthew FarrowandClaude Sonnet 5.5 ec43ac5a1e Read Illustrator EPS and .ai layers from their editing data
An Illustrator EPS is a flattened page plus, after %%EOF, an editing copy of
the document; a PDF-compatible .ai carries the same in its private data.
Importing only the page lost layer names, order, visibility, hidden layers
and hidden objects and hidden text.

- eps: read the editing copy (Zstandard %AI24_DataStream, older zlib
  %AI9_DataStream) into layers, sublayers, groups, compound paths, clip
  groups and hidden objects, run by the existing PostScript interpreter.
  Falls back to the page, with a warning, when a shown layer uses an
  operator that isn't read or the art differs from the page by over 5%.
- eps: read the text document (ate.rs) to place type on hidden layers and
  objects; the page's type is matched to the file's text objects by
  position (pieces of a line included) and kept with the nearest one when
  none claims it. Area type and type on a path are left out with a note.
- pdf/engine: .ai files go through the same reader.
- engine/cli: a file whose import left things out isn't overwritten unless
  asked (acknowledgeLoss, --replace-lossy).
- eps: font names re-encoded with a *<n> suffix resolve to their family.
- i18n: es/it/ru for the new import messages; ROADMAP updated.

Container and text-document formats were inferred from user-owned files; the
Adobe file format specification was not read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:49:49 -04:00
bflatasticandClaude Opus 5.5 f8138e14ba Fix #546: DXF export writes each fill's outlines before its hatch
Laser and cutter software skips HATCH entities, so shapes that were only
filled (type, traced art) reached it as nothing. Each filled area is now
written as its outlines (polylines and splines, in the fill's colour)
followed by the solid hatch; R12 keeps writing the outlines alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:49:42 +07:00
BFlat f149f10059 Merge pull request #707 from DisasterAreaDesigns/fix-reencoded-font-names
EPS import: a font name re-encoded as <subset>+<name>*<n> names the font
2026-10-09 14:46:46 +07:00
BFlat a3f6eeb5cc Merge pull request #711 from storytold/fix/708-unmapped-glyphs
Fix #708: glyphs whose ToUnicode is U+FFFD don't import as "�"
2026-10-09 14:43:03 +07:00
bflatasticandClaude Opus 5.5 dc7c300c69 Fix #708: glyphs whose ToUnicode is U+FFFD don't import as "�"
A PDF or PDF-compatible .ai whose ToUnicode map gives some glyphs U+FFFD (as
some files do for the digits of an embedded CID font) imported those glyphs as
type showing the replacement character, so every digit came in as "�".

U+FFFD now counts as no mapping. Such a glyph takes its character from the
embedded font where it can (Adobe's CID tables for CID-keyed CFF, as before,
then the font program's own cmap), and otherwise keeps its outline from the
embedded font, so it looks as the file draws it. The import warns which font
had characters the file doesn't name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:34:17 +07:00
BFlat a9106b17a8 Merge pull request #709 from storytold/fix/703-templates-folder
Fix #703: the Templates folder is in the desktop's documents folder
2026-10-09 14:27:01 +07:00
Matthew FarrowandClaude Sonnet 5.5 003ce917df EPS import: a font name re-encoded as <subset>+<name>*<n> names the font
A program that re-encodes a font in an EPS sets the copy under a name with a
`*<n>` suffix (`KBKNNM+RollerBabyBV*1`). The suffix was part of the family
name, so the installed font was never found and text fell back to a
substitute. The suffix is dropped when `*` is followed only by digits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 03:16:11 -04:00
Jax SpenserandClaude Sonnet 5.5 29310175af Image Trace: Flat Logo mode (sub-pixel edges, restored corners, true lines and circles)
A fourth Image Trace mode for flat-colour logos. The existing tracer follows hard pixel
edges, so an outline can only be as good as the pixel grid; here each pixel is read as a
blend of the logo's flat colours, the blend is interpolated smoothly (cubic B-spline), and
every outline is placed where two colours are equally present, to a fraction of a pixel.

Outlines are then cleaned the way a person would:
- corners the blur rounded are put back (line fits either side of a bend, intersected);
- near-straight runs and near-circular arcs become exact lines and arcs (Snap Curves To Lines);
- the rest is fitted with as few cubic Beziers as the tolerance allows (Schneider);
- touching colours overlap by 0.06 px so no hairline gap opens between independent fits;
- shapes are painted biggest first; transparent areas fully enclosed by artwork (letters
  knocked out of a badge, the counters of an "o") are painted white so the logo reads the
  same on any background, or become real holes with Ignore White (each hole belongs to the
  smallest outline containing it);
- an outline whose fit strays more than 1.4 px from the raw contour is fitted plainly.

Colours are found automatically (and completed from pixels no blend of the known colours
explains, so a small accent is not lost) or named with `logoColors: ["#rrggbb", ...]` (white is
always its own class; colours closer than 12 count as one; at most ten). Gradients and photos
are refused with advice to use Color (a flat-colour score of the solid areas, 0.93). A
hard-edged source with no anti-aliasing is softened by under a pixel and fitted with looser
snap tolerances. Images over 1024 px on the long side are averaged down first (further, if
they are busy enough to make the unmixing expensive) and the result is mapped back exactly.
More real outlines than we will fit are refused, not silently truncated.

Also: a "Flat Logo" preset, the mode in the Image Trace panel (Method hidden: it always
stacks), the command docs and MCP prompt, es/fr/it/ru translations for the two new messages
and the mode name, and the ROADMAP.

Never-crash: no unwrap/expect/panic, every size and float is bounded and checked, with
tests for degenerate and extreme input (NaN/infinite parameters, 0x0 and 1x1 images, sparse
outlines, an anchor budget, a 200,000-entry palette) and a 250-run randomised no-panic
property test. The B-spline interpolation follows Unser et al. and Thevenaz et al., and the
curve fitter follows Schneider (Graphics Gems); both are written from the papers.

Measured (local experiment, not in CI): a flat-colour logo's vector original rendered down
to a soft 199x100 px PNG and traced. Mean edge error vs the original at 1600 px wide:
built-in colour presets 2.5 to 3.4 px, Flat Logo 0.78 px (0.48 px from a crisp source);
region overlap 0.88-0.91 vs 0.966. About 0.1 s for a 200 px logo.

Tests: 60 new in the trace crate (unmix, field, contours, geometry, Beziers, corners,
outline fitting, palette, end-to-end on synthetic shapes made in code) and 3 in the engine.
cargo xtask ci passes (fmt, clippy, test, assets, brands, layers, wasm): 4,290 tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 17:57:04 +11:00
storybored379andOpenAI Codex 829ec48581 M1: command catalog resource and version-aware MCP results
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-08 21:35:07 -07:00
storybored379andOpenAI Codex 2eb6b2fd7c M1: common MCP tools and strict argument handling
Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-08 21:26:41 -07:00
144 changed files with 15953 additions and 309 deletions

No files matched your search

Generated
+2
View File
@@ -4445,6 +4445,7 @@ dependencies = [
"kurbo 0.13.1",
"log",
"proptest",
"ruzstd",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -4476,6 +4477,7 @@ dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"ruzstd",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
+2
View File
@@ -62,6 +62,8 @@ skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.4"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
# Zstandard decoder (pure Rust, builds for wasm32 too): the editing copy an EPS carries of its art.
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
+1 -1
View File
@@ -200,7 +200,7 @@ and masks, text and images, with what didn't come in (effects, adjustments, brus
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
doesn't write Affinity files. [Scope and limits](crates/affinity/README.md).
The interface
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian and Russian (and Czech and Brazilian Portuguese in the menus).
speaks English, Japanese, Traditional and Simplified Chinese, Spanish, French, Italian, Russian and Ukrainian (and Czech and Brazilian Portuguese in the menus).
The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
+15 -14
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
## Where we are
@@ -38,7 +38,7 @@ since 2026-10-01.
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~4 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. Native `.ai` private data is out of scope by design; DWG has no open spec |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against Affinity's own thumbnails of 22 pinned public files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
@@ -107,7 +107,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian and Russian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Spanish, French, Italian, Russian and Ukrainian ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8).
- The macOS menu bar is built from the in-window menus (one model, `native_menu`) and laid out as a Mac app's: the VectorCraft menu holds About, Settings ▸ (each Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays Hide Edges), Hide Others, Show All and Quit; Window gains Minimize ⌃⌘M, Zoom and Bring All to Front, and Help the system's search field. Its key equivalents go back to egui as key presses, so user shortcuts, focused text fields and the Type tool's input method behave as in the window, and nothing fires twice; enabled, check marks and labels update in place. `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps the menus in the window; `ui.inspect` reports `nativeMenuBar`.
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
@@ -134,7 +134,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681), Measure, Type, Hand, Zoom, Rotate View.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
@@ -145,9 +145,10 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458).
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) and Russian (every `ru-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian and Russian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian and Russian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724).
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish, French, Italian, Russian and Ukrainian also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points or an image over 64 megapixels is refused with advice rather than running out of memory, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (`object.cropImage {trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl` or CSS, and loaded from `.vcswatches`, `.gpl` and swatch exchange (`.ase`) files (RGB, CMYK, Lab and Gray; global, spot and process; groups). Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
@@ -164,7 +165,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
- Affinity documents in (AF.1–AF.3): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. 22 public CC0/MIT/Apache-2.0 files, pinned and sha256-verified (`cargo xtask corpus --affinity`), must render within a few levels of the thumbnails Affinity saved in them; [scope, provenance and limits](crates/affinity/README.md).
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
@@ -177,18 +178,18 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| # | Milestone | Status | Est. remaining, one agent (h) |
|---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; construction widget/refinements pending) | 12–18 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.3 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
@@ -219,18 +220,18 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 24% | ~52 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur and Unsharp Mask are done | 26–40 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (an on-canvas crop box), per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 |
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
| Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394) | 2–4 |
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (effects, adjustments, brushes, master pages, live symbol instances), DWG (no open spec: DXF instead), PSD placement as layers, EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (area type and type on a path, appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | macOS native menu dismissal fixed (M14.8); remaining: Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 20 Preferences options stored but not yet acted on (#394: Spacing Guides, Show Font Names in English, Highlight Alternate Glyphs, Display Print Size, Open Documents As Tabs, Real-time Drawing, hyphenation, Appearance of Black, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 5–8 |
| Libraries, Links, Package | 2 | 80% | the Libraries panel is local only (no cloud by design: libraries are files on this machine) and has no shared libraries, linked graphics or gradients and patterns as items yet; its graphics, colours and text styles are done (#745); Links and Package are done; placed VectorCraft documents are done (linked by default, vectors in every output, previews, Break Link, Expand, Edit Original, refresh on save) | 2–3 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
@@ -249,7 +250,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
## Out of scope (by design or by law)
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
- **Native `.ai` private data, beyond its structure:** the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours), linear and radial gradients, opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type that shows comes from the page, which draws it, into its text object's place (no group around it); type that doesn't (hidden layers and objects, art off the page) is made from the file's text document (characters, font, size, colour, alignment and where it is, for point type), and area type and type on a path in it are left out with a note. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type made from the text document and other type left out with a note. Symbols, pattern fills, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike); the page is used when they differ by more than 5%. A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
+8 -5
View File
@@ -4,7 +4,7 @@
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--replace-lossy]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000]
@@ -71,12 +71,14 @@ USAGE:
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--replace-lossy]
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
Live effects are kept, and hidden layers and objects (written hidden in SVG and PSD), with
SVG's data-* attributes; --outline-text writes SVG text as paths.
SVG's data-* attributes; --outline-text writes SVG text as paths. OUT may not be IN when reading
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
--replace-lossy is given.
vectorcraft-cli info FILE
Print a JSON summary: the import warnings (what didn't come in as it was, such as an EPS
@@ -173,7 +175,7 @@ fn commands() -> Result<(), String> {
fn convert(args: &[String]) -> Result<(), String> {
let mut files = vec![];
let (mut scale, mut artboard, mut range, mut outline_text) = (1.0f64, None::<u64>, None::<String>, false);
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy) = (1.0f64, None::<u64>, None::<String>, false, false);
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
@@ -181,6 +183,7 @@ fn convert(args: &[String]) -> Result<(), String> {
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
"--outline-text" => outline_text = true,
"--replace-lossy" => replace_lossy = true,
f => files.push(f.to_string()),
}
}
@@ -189,7 +192,7 @@ fn convert(args: &[String]) -> Result<(), String> {
h.call("app.open", json!({"path": input})).map_err(|e| format!("open {input}: {e}"))?;
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
// PSD).
let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true});
let params = json!({"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
let r = h.call("engine.execute", json!({"command": "document.export", "params": params})).map_err(|e| format!("export {output}: {e}"))?;
outln!("{r}");
Ok(())
+1 -1
View File
@@ -137,7 +137,7 @@ fn mcp_protocol_over_stdio() {
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 2));
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
+6
View File
@@ -489,6 +489,12 @@ fn main() -> std::process::ExitCode {
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
// So do the Libraries panel's libraries; runs without preferences (agents' test
// runs) keep theirs for the session only, never touching the user's.
if prefs_enabled() {
let libraries = prefs_path().and_then(|p| Some(p.parent()?.join("Libraries").to_string_lossy().to_string()));
app.session.libraries.set_dir(libraries);
}
// Data Recovery copies live next to the preferences too (none for runs without
// preferences, such as agents' test runs, unless the recoveryFolder preference is set).
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
+5
View File
@@ -35,3 +35,8 @@ sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.AutoHenry]
real_name = "Hal Goff"
display_name = "Hal Goff, HG Studio"
sync_github_name = false
+3 -2
View File
@@ -68,7 +68,8 @@ of CC0 sample files.
* **Meaning fitted to Affinity's own pictures.** What shapes, paints, text and pixel data mean was
worked out by comparing our render with the thumbnail every Affinity document embeds (Affinity's
render of itself), on 176 public documents saved by Affinity 1.x, 2.x and 3.0/3.1 on Windows,
macOS and iPad. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others
macOS and iPad. The collection has since grown to 189 distinct documents, and the reader opens
all of them. Most are published under CC0, MIT, BSD, Apache-2.0 or CC BY (-SA); the others
(no licence stated, or GPL or non-commercial terms) were only opened locally to compare
pictures, never committed or redistributed. The corpus test keeps the comparison for the pinned
files, which are CC0, MIT or Apache-2.0 only.
@@ -111,7 +112,7 @@ Every size is checked before it is allocated: 256 MiB per archive entry and 1 Gi
default (`Limits`), a 64 MiB zstd window, 4096 saved revisions, array lengths no longer than the
bytes left, 16 777 216 decoded values per document stream (fields and array elements together, counted before
anything is allocated: a value takes about 40 bytes in memory however few it took in the file; the largest of
189 distinct public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
nodes per curve, 64 megapixels per pixel layer (cropped to its content first) and 1024 gradient
stops. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
+3 -3
View File
@@ -2,9 +2,9 @@
//!
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
//! become polylines (straight segments) or cubic splines (curves), fills solid hatches, type text
//! entities or glyph outlines, and placed images image entities linked to PNG or JPEG files the
//! caller writes next to the drawing. Coordinates are y-up, in drawing units: `scale` units per
//! become polylines (straight segments) or cubic splines (curves), fills their outlines and solid
//! hatches, type text entities or glyph outlines, and placed images image entities linked to PNG
//! or JPEG files the caller writes next to the drawing. Coordinates are y-up, in drawing units: `scale` units per
//! `unit` of the art, with the origin at the bottom-left corner of [`DxfOptions::region`].
//!
//! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns,
+9 -5
View File
@@ -1,6 +1,7 @@
//! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint
//! order. Fills are solid hatches (outlines in R12), strokes polylines and cubic splines with a
//! lineweight and dashes, or filled outlines where the look needs them.
//! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes
//! polylines and cubic splines with a lineweight and dashes, or filled outlines where the look
//! needs them.
use std::collections::HashMap;
use std::io::Cursor;
@@ -504,8 +505,9 @@ impl<'a> Scene<'a> {
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
}
/// A filled area as a solid hatch (R12: its outlines). `clean`: the path has no overlaps, so
/// the hatch's even-odd rule reads it as it is.
/// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser
/// and cutter software reads the outlines and skips hatches. `clean`: the path has no
/// overlaps, so the hatch's even-odd rule reads it as it is.
fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) {
if !self.o.version.handles() {
self.warn(NO_FILLS);
@@ -517,10 +519,12 @@ impl<'a> Scene<'a> {
.then(|| vectorcraft_pathops::try_normalize(&PathData::from_bezpath(bp), rule).ok())
.flatten()
.map(|p| p.to_bezpath());
let loops: Vec<Vec<Point>> = polygons(normalized.as_ref().unwrap_or(bp)).into_iter().filter(|p| p.len() >= 3).collect();
let outline = normalized.as_ref().unwrap_or(bp);
let loops: Vec<Vec<Point>> = polygons(outline).into_iter().filter(|p| p.len() >= 3).collect();
if loops.is_empty() {
return;
}
self.lines(outline, style, 0.0);
let h = self.handle();
let layer = self.layer.clone();
self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch");
+44 -26
View File
@@ -129,30 +129,42 @@ fn every_version_declares_its_acadver_and_is_well_formed() {
fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
let d = shapes_doc();
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&out), ["HATCH", "LWPOLYLINE", "SPLINE"], "fill, then stroke, then the ellipse's stroke");
assert_eq!(
kinds(&out),
["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"],
"the fill's outline and hatch, then the stroke, then the ellipse's stroke"
);
let e = entities(&out);
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
let poly = &e[1];
assert_eq!(get(poly, 70), Some("1"), "closed");
let mut xs = all(poly, 10);
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [10.0, 10.0, 110.0, 110.0]);
let mut ys = all(poly, 20);
ys.sort_by(f64::total_cmp);
assert_eq!(ys, [40.0, 40.0, 90.0, 90.0]);
let hatch = &e[0];
let corners = |poly: &Pairs| {
let mut xs = all(poly, 10);
xs.sort_by(f64::total_cmp);
let mut ys = all(poly, 20);
ys.sort_by(f64::total_cmp);
(xs, ys)
};
let rect = (vec![10.0, 10.0, 110.0, 110.0], vec![40.0, 40.0, 90.0, 90.0]);
for poly in [&e[0], &e[2]] {
assert_eq!(get(poly, 70), Some("1"), "closed");
assert_eq!(corners(poly), rect);
}
// Laser and cutter software skips hatches: the fill's outline comes first, without a lineweight.
assert_eq!((get(&e[0], 370), get(&e[2], 370)), (None, Some("35")));
let hatch = &e[1];
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
// The ellipse: four cubics through clamped knots.
let spline = &e[2];
let spline = &e[3];
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
assert_eq!(get(&entities(&r14)[1], 43), Some("1"));
assert_eq!(get(&entities(&r14)[1], 370), None);
assert_eq!(get(&entities(&r14)[2], 43), Some("1"));
assert_eq!(get(&entities(&r14)[2], 370), None);
assert_eq!(get(&entities(&r14)[0], 43), None, "a fill's outline has no width");
// R13 has splines and hatches, and polylines of vertices.
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
assert_eq!(kinds(&r13), ["HATCH", "POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND", "SPLINE"]);
let poly = ["POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND"];
assert_eq!(kinds(&r13), [&poly[..], &["HATCH"], &poly, &["SPLINE"]].concat());
// R12: polylines only; the fill is its outline, the ellipse flattened.
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let k = kinds(&r12);
@@ -171,8 +183,9 @@ fn colours_map_to_the_index_palette_or_true_colour() {
};
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(style(&out, 0), (Some("1".into()), Some("16711680".into())), "red: index 1, true colour 0xFF0000");
assert_eq!(style(&out, 1), (Some("7".into()), Some("0".into())), "black is index 7");
assert_eq!(style(&out, 2), (Some("5".into()), Some("255".into())));
assert_eq!(style(&out, 1), style(&out, 0), "the fill's outline has the fill's colour");
assert_eq!(style(&out, 2), (Some("7".into()), Some("0".into())), "black is index 7");
assert_eq!(style(&out, 3), (Some("5".into()), Some("255".into())));
for depth in [ColorDepth::Aci8, ColorDepth::Aci16, ColorDepth::Aci256] {
let o = export(&d, &DxfOptions { colors: depth, ..opts(&d) }).unwrap();
assert_eq!(style(&o, 0), (Some("1".into()), None), "{depth:?}");
@@ -196,7 +209,7 @@ fn colours_map_to_the_index_palette_or_true_colour() {
fn units_scale_coordinates_and_set_insunits() {
let d = shapes_doc();
let insunits = |o: &DxfOutput| header(&pairs(&o.bytes), "$INSUNITS");
let max_x = |o: &DxfOutput| all(&entities(o)[1], 10).into_iter().fold(f64::MIN, f64::max);
let max_x = |o: &DxfOutput| all(&entities(o)[2], 10).into_iter().fold(f64::MIN, f64::max);
let mm = export(&d, &DxfOptions { unit: Unit::Millimeters, ..opts(&d) }).unwrap();
assert_eq!(insunits(&mm).as_deref(), Some("4"));
assert!((max_x(&mm) - 110.0 * 25.4 / 72.0).abs() < 1e-5);
@@ -208,9 +221,9 @@ fn units_scale_coordinates_and_set_insunits() {
assert_eq!(insunits(&scaled).as_deref(), Some("0"));
assert!((max_x(&scaled) - 110.0 / 72.0 * 10.0).abs() < 1e-5);
// Lineweights: 1 pt is 0.35 mm, scaled with the drawing only when asked.
assert_eq!(get(&entities(&scaled)[1], 370), Some("35"));
assert_eq!(get(&entities(&scaled)[2], 370), Some("35"));
let heavy = export(&d, &DxfOptions { unit: Unit::Inches, scale: 2.0, scale_lineweights: true, ..opts(&d) }).unwrap();
assert_eq!(get(&entities(&heavy)[1], 370), Some("70"));
assert_eq!(get(&entities(&heavy)[2], 370), Some("70"));
assert!(export(&d, &DxfOptions { scale: 0.0, ..opts(&d) }).is_err());
assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err());
}
@@ -238,7 +251,7 @@ fn layers_become_dxf_layers() {
assert_eq!(names, ["0", "Layer 1", "Dims_ _top_", "Notes"], "the template layer is left out");
assert!(layers[3].1.starts_with('-'), "a hidden layer is switched off");
let on: Vec<String> = entities(&out).iter().map(|e| get(e, 8).unwrap().to_string()).collect();
assert_eq!(on, ["Layer 1", "Layer 1", "Layer 1", "Dims_ _top_", "Notes"]);
assert_eq!(on, ["Layer 1", "Layer 1", "Layer 1", "Layer 1", "Dims_ _top_", "Dims_ _top_", "Notes", "Notes"]);
// Before 2000: capitals, letters, digits and $-_ only.
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let names: Vec<String> = table(&r12, "LAYER").iter().map(|o| get(o, 2).unwrap().to_string()).collect();
@@ -270,18 +283,21 @@ fn dashes_become_linetypes_and_opacity_transparency() {
fn alter_paths_writes_strokes_as_filled_outlines() {
let d = shapes_doc();
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
assert_eq!(kinds(&out), ["HATCH", "HATCH", "HATCH"]);
// Each fill and stroke: its outlines, then its hatch.
let fill = ["LWPOLYLINE", "HATCH"];
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["SPLINE", "SPLINE", "HATCH"]].concat());
// The rectangle's stroke: an outer and an inner boundary.
assert_eq!(get(&entities(&out)[1], 91), Some("2"));
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
// Preserve Appearance outlines only the strokes a line can't draw.
let mut d = shapes_doc();
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
st.align = vectorcraft_doc::StrokeAlign::Outside;
}
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["HATCH", "HATCH", "SPLINE"]);
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), [&fill[..], &rect_stroke, &["SPLINE"]].concat());
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["HATCH", "LWPOLYLINE", "SPLINE"]);
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "SPLINE"]);
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
}
@@ -308,7 +324,9 @@ fn type_is_text_or_outlines() {
// Outline Text, and Preserve Appearance: the glyphs as filled outlines.
for o in [DxfOptions { preserve: Preserve::Editability, outline_text: true, ..opts(&d) }, opts(&d)] {
let k = kinds(&export(&d, &o).unwrap());
assert!(!k.is_empty() && k.iter().all(|k| k == "HATCH"), "{k:?}");
assert!(k.iter().any(|k| k == "HATCH"), "{k:?}");
assert!(k.iter().all(|k| matches!(k.as_str(), "HATCH" | "LWPOLYLINE" | "SPLINE")), "{k:?}");
assert_eq!(k.first().map(String::as_str), Some("SPLINE"), "a glyph's outline before its hatch");
}
}
@@ -367,5 +385,5 @@ fn crop_leaves_out_art_outside_the_region() {
// Only the ellipse (120..180) reaches into 115..200.
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 3);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 4);
}
+13 -5
View File
@@ -86,9 +86,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, InlineArt, Justify, LeadingModel, Mojikumi,
ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap,
VerticalAlign, WrapShape,
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, InlineArt, Justify, Kinsoku, LeadingModel,
Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef,
TextWrap, VerticalAlign, WrapShape,
};
pub use trace::TraceView;
pub use vectorcraft_color as color;
@@ -96,7 +96,7 @@ pub use vectorcraft_geom as geom;
use serde::{Deserialize, Serialize};
use vectorcraft_color::{Swatch, SwatchGroup};
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DocError {
@@ -741,7 +741,8 @@ impl Document {
}
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
max = self.assets.iter().fold(max, |m, a| m.max(a.id));
self.next_id = self.next_id.max(max + 1);
// An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst.
self.next_id = self.next_id.max(max.saturating_add(1));
}
/// Find a node anywhere in the tree.
@@ -1006,6 +1007,13 @@ impl Document {
*g = g.moved(d);
}
}
/// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork
/// with Artboard).
pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) {
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y };
}
}
/// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their
/// artboard).
pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) {
+24
View File
@@ -341,6 +341,9 @@ pub struct ParaStyle {
/// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before
/// it and imported text keep [`Burasagari::None`].
pub burasagari: Burasagari,
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
/// line.
pub kinsoku: Kinsoku,
}
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
@@ -378,6 +381,8 @@ struct ParaStyleFile {
leading_model: LeadingModel,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
burasagari: Burasagari,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kinsoku: Kinsoku,
}
impl From<ParaStyle> for ParaStyleFile {
@@ -402,6 +407,7 @@ impl From<ParaStyle> for ParaStyleFile {
direction,
leading_model,
burasagari,
kinsoku,
..
} = p;
Self {
@@ -420,6 +426,7 @@ impl From<ParaStyle> for ParaStyleFile {
direction,
leading_model,
burasagari,
kinsoku,
}
}
}
@@ -441,6 +448,7 @@ impl From<ParaStyleFile> for ParaStyle {
direction,
leading_model,
burasagari,
kinsoku,
..
} = f;
Self {
@@ -458,6 +466,7 @@ impl From<ParaStyleFile> for ParaStyle {
direction,
leading_model,
burasagari,
kinsoku,
}
}
}
@@ -504,6 +513,21 @@ impl Mojikumi {
}
}
/// Which Japanese characters may not start or end a line (kinsoku shori).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Kinsoku {
/// No kinsoku: a line may break between any two characters that allow a break.
None,
/// Closing brackets, commas, full stops, iteration marks, the prolonged sound mark, small
/// kana and the like don't start a line; opening brackets don't end one.
#[default]
Hard,
/// As Hard, except that 々, the prolonged sound mark ー and small kana may start a line
/// (JLREQ's level 3 line-breaking rules, Appendix C.3).
Soft,
}
/// Hanging punctuation (burasagari): an East Asian comma or full stop ending a line (、。,.、。)
/// stands outside the line's measure, in the space its punctuation spacing gives it (half width
/// with Line-end Punctuation Half Width). Closing brackets and Latin punctuation don't hang. Shown
+2
View File
@@ -40,3 +40,5 @@ crc32fast = { workspace = true }
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
wat = { workspace = true }
# Zstandard streams for the Illustrator editing data fuzz tests.
ruzstd = { workspace = true }
+1 -1
View File
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Image Trace"],
None,
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult)} → {id, paths, anchors, colors}",
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite, snapCurvesToLines, fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, white is always its own colour, at most ten; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white unless ignoreWhite makes them holes; fills/strokes are ignored; gradients and photos are refused)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult)} → {id, paths, anchors, colors}",
has_selection_or_ids,
|s, p| trace_make(s, p, false)
),
+8 -1
View File
@@ -154,11 +154,18 @@ impl Clipboard {
}
vectorcraft_brush::store(&mut d, &lib);
}
let mut layer = Node::layer(NodeId(u64::MAX), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
// The layer's id is above every copied object's, so the document saves and reopens as
// a native file.
let mut top = 0;
for n in &self.nodes {
n.walk(&mut |c| top = top.max(c.id.0));
}
let mut layer = Node::layer(NodeId(top.saturating_add(1)), "Clipboard", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = self.nodes.iter().cloned().map(Arc::new).collect();
}
d.layers = vec![Arc::new(layer)];
d.fix_next_id();
d
}
}
+12 -1
View File
@@ -287,6 +287,8 @@ enum PasteMode {
Back,
InPlace,
AllArtboards,
/// A graphic from the Libraries panel: centred where it is placed, as Paste centres.
Library,
}
impl PasteMode {
@@ -297,6 +299,7 @@ impl PasteMode {
PasteMode::InPlace => "Paste in Place",
PasteMode::AllArtboards => "Paste on All Artboards",
PasteMode::Offset => "Paste",
PasteMode::Library => "Place from Library",
}
}
}
@@ -310,6 +313,14 @@ fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
r
}
/// Place the objects of `clip` (a library graphic) into the active document centred on `center`,
/// as one undo step: their resources come along as a paste's do, a swatch whose name the document
/// gives another colour merged into the document's.
pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_geom::Point) -> Result<Value> {
let choices = SwatchChoices::parse(PasteMode::Library.label(), &json!({}))?;
paste_clip(s, &json!({ "center": [center.x, center.y] }), PasteMode::Library, clip, &choices)
}
/// Paste in Place, in Front or in Back onto artboard `artboard` (the active one, #693): where the
/// objects were on the artboard they were copied from, on that one; without it (or a copy from no
/// artboard), where they were.
@@ -348,7 +359,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
};
let placements: Vec<Affine> = match (mode, board) {
(_, Some((_, dv))) => vec![Affine::translate(dv)],
(PasteMode::Offset, None) => vec![match point_param(p, "center") {
(PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") {
Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())),
None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))),
}],
+1 -1
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export DXF",
[],
None,
"{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (every stroke as its filled outline), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills solid hatches. Same as document.export {format: dxf}",
"{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (every stroke as its filled outline), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
has_doc,
export_dxf
)]
+9
View File
@@ -45,6 +45,9 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path");
if let Some(path) = path {
super::check_not_lossy_overwrite(s.doc()?, path, p, "document.export")?;
}
let f = writable("document.export", str_param(p, "format"), path)?;
let expanded = expand_presets(s, "document.export", p)?;
let p = &*expanded;
@@ -166,6 +169,9 @@ pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Do
/// template layers are guides, not artwork, and are left out.
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportSelection";
if let Some(path) = str_param(p, "path") {
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
}
let path = str_param(p, "path");
let f = writable(C, str_param(p, "format"), path)?;
let expanded = expand_presets(s, C, p)?;
@@ -181,6 +187,9 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
/// `transparent`.
pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportForOffice";
if let Some(path) = str_param(p, "path") {
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
}
let path = str_param(p, "path");
let ppi = f64_or(p, "ppi", 150.0);
let doc = &s.doc()?.doc;
+88 -1
View File
@@ -133,6 +133,37 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
load_with(name, bytes, &LoadOptions::default())
}
/// A PDF-compatible `.ai` read through the editor's own copy of its art, which it carries: its
/// layers and hidden objects as they were, and the art outside its artboard (its PDF part has
/// only what is on the artboards), as an EPS of the editor is read. Anything else as it was.
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
if opts.pages.is_some() || !opts.layers {
return (doc, warnings);
}
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
let (doc, mut warnings) = vectorcraft_eps::layered_ai(&private, doc, warnings);
// The note that the art outside the artboard is lost is the reason the layers weren't read.
if warnings.iter().any(|w| w.starts_with("the file's layers weren't read from its editing data")) {
return (doc, warnings);
}
warnings.retain(|w| w != vectorcraft_pdf::OFF_ARTBOARD_NOTE);
(doc, warnings)
}
/// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page) from the editor's
/// own copy of its art alone: `None` when the file has none (or pages are picked, or layers are
/// off), an error when it can't be read.
fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(Document, Vec<String>)>> {
if opts.pages.is_some() || !opts.layers {
return None;
}
let private = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref())?;
Some(
vectorcraft_eps::ai_alone(&private)
.map_err(|why| err(format!("this file was saved without PDF compatibility, and its editing data can't be read ({why})"))),
)
}
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
@@ -171,7 +202,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || super::pdfimport::import(bytes, opts))?
restore_or_import(editing, || match super::pdfimport::import(bytes, opts) {
Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)),
// Saved without its PDF part: the editing copy is all the file has.
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
}
Err(e) => Err(e),
})?
}
"dxf" => {
let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?;
@@ -244,6 +282,9 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
/// Make a loaded file the new active document; `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded;
// What reading the file left out of the document, if anything.
let losses: Vec<String> = warnings.iter().filter(|w| is_loss(w)).cloned().collect();
let source_path = path.clone().filter(|_| !losses.is_empty());
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
// A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled
// document.
@@ -266,6 +307,8 @@ fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &Loa
st.format = format.id;
}
st.converted = converted;
st.imported_from = source_path;
st.import_losses = losses;
let title = s.documents()[index].title();
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json()))
}
@@ -360,3 +403,47 @@ pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
d.insert(Some(layer), 0, n).map_err(err)?;
Ok(d)
}
/// Does this import note say that the file has something the document doesn't?
fn is_loss(note: &str) -> bool {
note == vectorcraft_pdf::OFF_ARTBOARD_NOTE || vectorcraft_eps::is_loss(note)
}
/// Writing the file `target` for document `st`: not over the file `st` was read from when reading
/// it left something out (hidden text, art or layers), unless `p` has `acknowledgeLoss: true`.
/// Another name keeps the original file, with all it has.
pub(crate) fn check_not_lossy_overwrite(st: &crate::DocState, target: &str, p: &Value, cmd: &str) -> Result<()> {
let Some(losses) = overwrite_losses(st, target, p) else { return Ok(()) };
let what = losses_summary(losses);
Err(bad(
cmd,
format!(
"{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway"
),
))
}
/// What writing the file `target` for document `st` would lose for good: the notes of what reading
/// it left out when `target` is the file `st` was read from, unless `p` has `acknowledgeLoss: true`.
/// The UI asks before such a write and repeats it with `acknowledgeLoss` ([`check_not_lossy_overwrite`]).
pub fn overwrite_losses<'a>(st: &'a crate::DocState, target: &str, p: &Value) -> Option<&'a [String]> {
let source = st.imported_from.as_deref().filter(|_| !st.import_losses.is_empty())?;
(!bool_or(p, "acknowledgeLoss", false) && same_file(source, target)).then_some(st.import_losses.as_slice())
}
/// Up to three of `losses`, quoted, and how many more.
pub fn losses_summary(losses: &[String]) -> String {
let mut what = losses.iter().take(3).map(|n| format!("“{n}”")).collect::<Vec<_>>().join("; ");
if losses.len() > 3 {
what.push_str(&format!("; {} more", losses.len() - 3));
}
what
}
/// Do `a` and `b` name one file (spelled differently, or through a link)?
fn same_file(a: &str, b: &str) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a), Ok(b)) => a == b,
_ => a == b,
}
}
+6
View File
@@ -47,11 +47,14 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, enc
pub(crate) use encode::{anti_alias, background, with_single_artboard};
pub use export::export_source;
pub(crate) use export::isolated;
pub(crate) use load::check_not_lossy_overwrite;
use load::err;
pub(crate) use load::import_svg;
pub(crate) use load::native_file;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
pub use load::{losses_summary, overwrite_losses};
pub(crate) use native::preview_png;
pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
@@ -936,6 +939,9 @@ mod tests_psd;
#[cfg(test)]
mod tests_epsimport;
#[cfg(test)]
mod tests_aiimport;
#[cfg(test)]
mod tests_pdflayers;
+1 -1
View File
@@ -186,7 +186,7 @@ pub(super) fn ai_native(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
/// The first artboard (else the art) as a PNG fitted into [`PREVIEW_MAX`] pixels (`None`: nothing
/// to show).
fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
pub(crate) fn preview_png(doc: &Document) -> Result<Option<Vec<u8>>> {
let Some(r) = doc.artboards.first().map(|a| a.rect).or_else(|| vectorcraft_render::encode::art_bounds(doc)) else { return Ok(None) };
let scale = f64::from(PREVIEW_MAX) / r.width().max(r.height());
if vectorcraft_render::raster_size(r, scale).is_err() {
+4 -1
View File
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export PDF",
[],
None,
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options ac Line truncated
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null k Line truncated
has_doc,
export_pdf
),
@@ -356,6 +356,9 @@ fn without_non_printing(layers: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = str_param(p, "path") {
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
}
let expanded = expand_preset(s, C, p)?;
let p = &*expanded;
let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?;
+3
View File
@@ -232,6 +232,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
_ => st.path.as_deref().and_then(parent_folder),
};
let modified = date_param(p, "modified", cmd)?;
if let Some(path) = &path {
super::check_not_lossy_overwrite(st, path, p, cmd)?;
}
Ok(SavePlan { mode, path, format, options, name, folder, modified })
}
@@ -0,0 +1,110 @@
//! Illustrator EPS and `.ai` files open from the editing data they carry: layers, groups, names,
//! hidden objects, artboards, colours and images as they were; what the reader doesn't read yet
//! opens as before, with a warning saying why.
use std::io::Write as _;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_testkit::ai;
use super::*;
fn open(s: &mut Session, name: &str, bytes: &[u8], params: Value) -> Result<Value> {
let mut p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)});
if let (Some(p), Some(extra)) = (p.as_object_mut(), params.as_object()) {
p.extend(extra.clone());
}
s.execute("document.open", &p)
}
/// The `.ai` private data of `data`, compressed as older `.ai` files compress it.
fn compressed(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
[&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat()
}
fn names(nodes: &[Arc<Node>]) -> Vec<String> {
nodes.iter().map(|n| n.name.clone().unwrap_or_default()).collect()
}
/// The sample's structure, as [`ai::sample_data`] builds it.
fn check_sample(s: &Session) {
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art", "Hidden"]);
assert_eq!(doc.artboards.len(), 1);
assert_eq!(doc.artboards[0].rect, vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0));
let art = doc.layers[0].children().unwrap();
assert_eq!(names(art), ["Pair", "Ring", "Window"]);
let pair = art[0].children().unwrap();
assert_eq!(names(pair), ["Red", ""]);
assert!(pair[0].visible && !pair[1].visible, "the hidden member stays hidden");
assert!(matches!(art[1].kind, NodeKind::Compound { .. }));
assert!(matches!(&art[2].kind, NodeKind::Group { clip: true, children } if matches!(children[0].kind, NodeKind::Path { clipping: true, .. })));
let hidden = &doc.layers[1];
assert!(!hidden.visible);
let kids = hidden.children().unwrap();
assert_eq!(names(kids), ["Shaded", "", "Sub", ""]);
assert!(matches!(kids[0].appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Gradient(_))));
assert_eq!((kids[1].opacity, kids[1].blend), (0.5, vectorcraft_color::BlendMode::Screen));
assert!(kids[2].locked && matches!(kids[2].kind, NodeKind::Layer { .. }));
let NodeKind::Image(im) = &kids[3].kind else { panic!("an image") };
assert_eq!((im.width, im.height), (2, 1));
}
#[test]
fn an_illustrator_eps_opens_with_its_layers() {
let mut s = Session::new();
let r = open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
assert_eq!((&r["format"], &r["warnings"]), (&json!("eps"), &json!([])), "{r}");
check_sample(&s);
// Saved as EPS and opened again, it comes back the same.
let eps = s.execute("document.exportEps", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(eps["dataBase64"].as_str().unwrap()).unwrap();
let r = open(&mut s, "again.eps", &bytes, json!({})).unwrap();
assert_eq!(r["restored"], json!(true), "{r}");
check_sample(&s);
}
#[test]
fn an_illustrator_ai_file_opens_from_its_editing_data() {
let mut s = Session::new();
let file = ai::ai(&compressed(&ai::sample_data()), ai::page_pdf());
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
assert_eq!(r["format"], json!("ai"), "{r}");
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("weren't read")), "{r}");
check_sample(&s);
// Pages picked: the PDF's.
open(&mut s, "art.ai", &file, json!({"pages": "1"})).unwrap();
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
}
#[test]
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
let mut s = Session::new();
let r = open(&mut s, "symbols.eps", &ai::eps(symbols.as_bytes(), ai::page_ps()), json!({})).unwrap();
assert!(r["warnings"].to_string().contains("layers weren't read") && r["warnings"].to_string().contains("symbols"), "{r}");
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Layer 1"]);
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}");
}
/// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has.
const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET";
#[test]
fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
let mut s = Session::new();
let r = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({})).unwrap();
assert_eq!(r["format"], json!("ai"), "{r}");
check_sample(&s);
// Its editing data damaged: it says so (the placeholder isn't the art).
let e = open(&mut s, "damaged.ai", &ai::ai(b"%AI12_CompressedData not zlib", PLACEHOLDER), json!({})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
// Without editing data, the placeholder still isn't opened as the art.
let e = open(&mut s, "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
+3 -3
View File
@@ -52,7 +52,7 @@ fn export_writes_dxf_of_the_chosen_version() {
let dxf = text(&r);
assert_eq!(header(&dxf, "$ACADVER").as_deref(), Some("AC1032"));
assert_eq!(header(&dxf, "$INSUNITS").as_deref(), Some("4"), "millimetres by default");
assert_eq!(entities(&dxf), ["HATCH", "HATCH"], "the whole document, origin at the first artboard");
assert_eq!(entities(&dxf), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "HATCH"], "the whole document, origin at the first artboard");
for (v, acadver) in [("R12", "AC1009"), ("R14", "AC1014"), ("2000", "AC1015"), ("2004", "AC1018"), ("2013", "AC1027")] {
let r = s.execute("document.exportDxf", &json!({"version": v})).unwrap();
assert_eq!(header(&text(&r), "$ACADVER").as_deref(), Some(acadver), "{v}");
@@ -71,11 +71,11 @@ fn use_artboards_writes_one_drawing_per_artboard() {
let names: Vec<&str> = files.iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Untitled-1-Artboard-1.dxf", "Untitled-1-Artboard-2.dxf"]);
for f in files {
assert_eq!(entities(&text(f)), ["HATCH"], "each holds the art over its artboard");
assert_eq!(entities(&text(f)), ["LWPOLYLINE", "HATCH"], "each holds the art over its artboard");
}
let r = s.execute("document.export", &json!({"format": "dxf", "useArtboards": true, "range": "2"})).unwrap();
assert!(r.get("files").is_none());
assert_eq!(entities(&text(&r)), ["HATCH"]);
assert_eq!(entities(&text(&r)), ["LWPOLYLINE", "HATCH"]);
// Without artboards: the origin at the art's bounds.
let r = s.execute("document.export", &json!({"format": "dxf", "useArtboards": false, "unit": "pt"})).unwrap();
let dxf = text(&r);
+50 -9
View File
@@ -10,8 +10,8 @@ use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Affine, Rect};
use super::*;
use crate::EngineError;
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Artboard Options…",
["Window", "Artboards"],
None,
"{index, name?, x?, y?, width?, height?}",
"{index, name?, x?, y?, width?, height?, scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale)} → null, or with scaleArt {scaled: the art scaled}",
has_doc,
artboard_set
),
@@ -752,21 +752,62 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let now = rect_from(p, was);
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
// Scale Artwork with Artboard: resized, it takes the art fully inside it (as Move Artwork with
// Artboard gathers it) and its guides from the old rectangle onto the new one.
let scale_art = bool_or(p, "scaleArt", false);
let fit = if scale_art && resized { rect_map(was, now) } else { None };
let art = if fit.is_some() {
// Locked and hidden art too? Noted in the journal, so a replay scales the same objects
// whatever the preference is then.
let all = bool_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard);
s.note_journal("lockedAndHidden", json!(all));
s.doc()?.doc.art_on_artboard(was, all)
} else {
vec![]
};
let mut sc = match fit {
Some(xf) if Scaling::factor(xf).is_some() => super::object::scaling(s, p),
_ => Scaling::default(),
};
sc.patterns = super::object::transform_patterns(s, p, &art)?;
s.edit("Artboard Options", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let was = a.rect;
a.rect = rect_from(p, a.rect);
a.rect = now;
if let Some(n) = str_param(p, "name") {
a.name = n.to_string();
}
// Moved (not resized), it takes its guides along.
let (id, now) = (a.id, a.rect);
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
let id = a.id;
if let Some(xf) = fit {
d.map_artboard_guides(id, xf);
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(xf, sc);
}
}
} else if !resized {
// Moved (not resized), it takes its guides along.
d.move_artboard_guides(id, now.origin() - was.origin());
}
Ok(())
})?;
ok()
if !scale_art {
return ok();
}
Ok(json!({ "scaled": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// The map taking artboard rectangle `from` onto `to`, each side scaled by its own ratio. None
/// when `from` has no area or the map isn't finite (a size out of range).
fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
if from.width() <= 1e-9 || from.height() <= 1e-9 {
return None;
}
let (sx, sy) = (to.width() / from.width(), to.height() / from.height());
let xf = Affine::translate(to.origin().to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-from.origin().to_vec2());
xf.is_finite().then_some(xf)
}
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
+699
View File
@@ -0,0 +1,699 @@
//! Libraries (Window › Libraries): local libraries of graphics, colours and text styles that every
//! document can use. A library is one `.vclibrary` file (JSON) in the library folder next to the
//! preferences, which the desktop app sets; without a folder (the web, headless sessions) libraries
//! live for the session only. A graphic is kept as a native document with a PNG thumbnail: adding
//! one copies the selection as Copy does, and placing it pastes a copy as Paste does (its images,
//! symbols, patterns and swatches come along; a swatch name the document gives another colour is
//! merged into the document's).
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::TextStyleDef;
use super::*;
use crate::Clipboard;
/// The extension of a library file.
pub const LIBRARY_EXT: &str = "vclibrary";
/// Bytes a library file may have when read (graphics with large images included).
const MAX_FILE: u64 = 512 << 20;
/// Items of one kind a library may hold.
const MAX_ITEMS: usize = 10_000;
/// A library: its name and its items, newest last.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Library {
pub name: String,
#[serde(default)]
pub colors: Vec<LibraryColor>,
#[serde(default)]
pub char_styles: Vec<TextStyleDef>,
#[serde(default)]
pub para_styles: Vec<TextStyleDef>,
#[serde(default)]
pub graphics: Vec<LibraryGraphic>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LibraryColor {
pub name: String,
pub color: Color,
}
/// A graphic: the art as a native document (its own resources included) and its thumbnail.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LibraryGraphic {
pub id: String,
pub name: String,
/// Size of the art (points).
pub width: f64,
pub height: f64,
/// The native document, base64.
pub data: String,
/// A PNG of the art, at most 256 pixels a side, base64 (empty when it draws nothing).
#[serde(default)]
pub thumbnail: String,
}
/// The libraries of a session ([`Session::libraries`]).
#[derive(Clone, Debug, Default)]
pub struct Libraries {
/// The library folder: none in the web app and headless sessions.
dir: Option<String>,
/// (id: the file's stem, library), in name order.
libs: Vec<(String, Library)>,
/// The library the panel shows, which commands without a `library` act on.
current: Option<String>,
}
impl Libraries {
pub fn dir(&self) -> Option<&str> {
self.dir.as_deref()
}
/// Set the library folder and read its libraries (unreadable files are skipped).
pub fn set_dir(&mut self, dir: Option<String>) {
self.dir = dir;
self.libs.clear();
if let Some(dir) = self.dir.clone() {
for path in library_paths(&dir) {
let id = stem_of(&path);
let lib = super::fileio::file_stamp(&path)
.filter(|(len, _)| *len <= MAX_FILE)
.and_then(|_| super::fileio::read_file(&path).ok())
.and_then(|b| serde_json::from_slice::<Library>(&b).ok());
if let Some(mut lib) = lib {
lib.cap();
self.libs.push((id, lib));
}
}
}
self.sort();
}
fn sort(&mut self) {
self.libs.sort_by(|a, b| a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()).then(a.0.cmp(&b.0)));
}
/// The libraries, as (id, library), in name order.
pub fn all(&self) -> &[(String, Library)] {
&self.libs
}
/// The library the panel shows: the one chosen last, else the first.
pub fn current(&self) -> Option<&str> {
self.current.as_deref().filter(|c| self.get(c).is_some()).or_else(|| self.libs.first().map(|(i, _)| i.as_str()))
}
pub fn get(&self, id: &str) -> Option<&Library> {
self.libs.iter().find(|(i, _)| i == id).map(|(_, l)| l)
}
/// Library `key` by id, else by name (ignoring case).
fn find(&self, key: &str) -> Option<String> {
self.libs.iter().find(|(i, _)| i == key).or_else(|| self.libs.iter().find(|(_, l)| l.name.eq_ignore_ascii_case(key))).map(|(i, _)| i.clone())
}
/// A new empty library called `name` → its id.
fn create(&mut self, name: &str) -> Result<String> {
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '-' }).collect();
let base = base.trim();
let base = if base.is_empty() { "Library" } else { base };
let taken = |id: &str| self.libs.iter().any(|(i, _)| i.eq_ignore_ascii_case(id)) || self.file_exists(id);
let id = std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|id| !taken(id)).unwrap_or_default();
self.libs.push((id.clone(), Library { name: name.to_string(), ..Default::default() }));
if let Err(e) = self.save(&id) {
self.libs.retain(|(i, _)| i != &id);
return Err(e);
}
self.sort();
self.current = Some(id.clone());
Ok(id)
}
#[cfg(not(target_arch = "wasm32"))]
fn file_exists(&self, id: &str) -> bool {
self.path(id).is_some_and(|p| std::path::Path::new(&p).exists())
}
#[cfg(target_arch = "wasm32")]
fn file_exists(&self, _: &str) -> bool {
false
}
fn path(&self, id: &str) -> Option<String> {
let dir = self.dir.as_deref()?;
Some(std::path::Path::new(dir).join(format!("{id}.{LIBRARY_EXT}")).to_string_lossy().into_owned())
}
/// Write library `id` to its file (nothing to do without a folder).
fn save(&self, id: &str) -> Result<()> {
let (Some(path), Some(lib)) = (self.path(id), self.get(id)) else { return Ok(()) };
if let Some(dir) = self.dir.as_deref() {
super::fileio::create_dir(dir)?;
}
let bytes = serde_json::to_vec(lib).map_err(|e| EngineError::Other(e.to_string()))?;
super::fileio::write_file(&path, &bytes)
}
/// Change library `id` with `f` and save it; on a failed save the change is undone.
fn change<T>(&mut self, id: &str, f: impl FnOnce(&mut Library) -> Result<T>) -> Result<T> {
let i = self.libs.iter().position(|(i, _)| i == id).ok_or_else(|| EngineError::Other(format!("no library `{id}`")))?;
let before = self.libs.get(i).map(|(_, l)| l.clone()).unwrap_or_default();
let r = match self.libs.get_mut(i) {
Some((_, lib)) => f(lib)?,
None => return Err(EngineError::Other(format!("no library `{id}`"))),
};
if let Err(e) = self.save(id) {
if let Some((_, lib)) = self.libs.get_mut(i) {
*lib = before;
}
return Err(e);
}
self.sort();
Ok(r)
}
fn delete(&mut self, id: &str) -> Result<()> {
if let Some(path) = self.path(id) {
remove_file(&path)?;
}
self.libs.retain(|(i, _)| i != id);
if self.current.as_deref() == Some(id) {
self.current = None;
}
Ok(())
}
}
impl Library {
/// At most [`MAX_ITEMS`] of each kind (a hand-edited file may hold more).
fn cap(&mut self) {
self.colors.truncate(MAX_ITEMS);
self.char_styles.truncate(MAX_ITEMS);
self.para_styles.truncate(MAX_ITEMS);
self.graphics.truncate(MAX_ITEMS);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn library_paths(dir: &str) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
let mut v: Vec<String> = rd
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x.to_string_lossy().eq_ignore_ascii_case(LIBRARY_EXT)))
.map(|p| p.to_string_lossy().into_owned())
.collect();
v.sort();
v
}
#[cfg(target_arch = "wasm32")]
fn library_paths(_: &str) -> Vec<String> {
vec![]
}
#[cfg(not(target_arch = "wasm32"))]
fn remove_file(path: &str) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(EngineError::Other(format!("{path}: {e}"))),
}
}
#[cfg(target_arch = "wasm32")]
fn remove_file(_: &str) -> Result<()> {
Ok(())
}
fn stem_of(path: &str) -> String {
std::path::Path::new(path).file_stem().map_or_else(String::new, |s| s.to_string_lossy().into_owned())
}
/// The kinds of library item.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Graphic,
FillColor,
StrokeColor,
CharStyle,
ParaStyle,
}
impl Kind {
fn of(p: &Value, cmd: &str) -> Result<Self> {
Ok(match str_param(p, "kind") {
Some("graphic") => Kind::Graphic,
Some("fillColor") => Kind::FillColor,
Some("strokeColor") => Kind::StrokeColor,
Some("color") => Kind::FillColor,
Some("charStyle") => Kind::CharStyle,
Some("paraStyle") => Kind::ParaStyle,
_ => return Err(bad(cmd, "kind must be graphic, fillColor, strokeColor, charStyle or paraStyle")),
})
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "library.list",
"Libraries",
[],
None,
"{} → {libraries: [{id, name, graphics, colors, charStyles, paraStyles} (counts)], current: the current library's id or null, folder: the library folder, or null when libraries last for the session only (the web, headless)}",
always,
list
),
cmd!(
query "library.get",
"Library",
[],
None,
"{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}]}",
always,
get
),
cmd!(
"library.setCurrent",
"Show Library",
[],
None,
"{library: id or name} the library the Libraries panel shows, which library commands without a `library` act on → {id}",
always,
set_current
),
cmd!(
"library.create",
"Create New Library",
[],
None,
"{name?: \"My Library\"} a new empty library (its file in the library folder), made the current one → {id, name}",
always,
create
),
cmd!(
"library.rename",
"Rename Library",
[],
None,
"{library?, name} rename a library (default: the current one) → {id, name}",
always,
rename
),
cmd!(
"library.delete",
"Delete Library",
[],
None,
"{library?} delete a library (default: the current one) and its file; not undoable → null",
always,
delete
),
cmd!(
"library.add",
"Add to Library",
[],
None,
"{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|charStyle|paraStyle, ids?: objects (default: the selection), name?} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). An item the library already has (same kind and contents) isn't added again → {library, kind, name, id?: graphic id, existing: bool}",
has_doc,
add
),
cmd!(
"library.use",
"Use Library Item",
[],
None,
"{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colours; default: the kind's)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colours: paint the selection (and the defaults) as paint.setFill/paint.setStroke; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}",
has_doc,
use_item
),
cmd!("library.removeItem", "Delete Library Item", [], None, "{library?, kind, item: name (graphic: id or name)} → null", always, remove_item),
]
}
/// The library `p` names (`library`: id or name), else the current one.
fn library_id(s: &Session, p: &Value, cmd: &str) -> Result<String> {
match str_param(p, "library") {
Some(key) => s.libraries.find(key).ok_or_else(|| bad(cmd, format!("no library `{key}`"))),
None => s.libraries.current().map(str::to_string).ok_or_else(|| bad(cmd, "there is no library: create one first")),
}
}
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.setCurrent";
let key = str_param(p, "library").ok_or_else(|| bad(C, "missing `library`"))?;
let id = s.libraries.find(key).ok_or_else(|| bad(C, format!("no library `{key}`")))?;
s.libraries.current = Some(id.clone());
Ok(json!({ "id": id }))
}
fn counts(lib: &Library) -> Value {
json!({
"graphics": lib.graphics.len(),
"colors": lib.colors.len(),
"charStyles": lib.char_styles.len(),
"paraStyles": lib.para_styles.len(),
})
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let libraries: Vec<Value> = s
.libraries
.all()
.iter()
.map(|(id, lib)| {
let mut v = json!({ "id": id, "name": lib.name });
if let (Some(o), Value::Object(c)) = (v.as_object_mut(), counts(lib)) {
o.extend(c);
}
v
})
.collect();
Ok(json!({ "libraries": libraries, "current": s.libraries.current(), "folder": s.libraries.dir() }))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let id = library_id(s, p, "library.get")?;
let lib = s.libraries.get(&id).ok_or_else(|| bad("library.get", format!("no library `{id}`")))?;
Ok(json!({
"id": id,
"name": lib.name,
"graphics": lib.graphics.iter().map(|g| json!({"id": g.id, "name": g.name, "width": g.width, "height": g.height, "thumbnail": g.thumbnail})).collect::<Vec<_>>(),
"colors": lib.colors.iter().map(|c| json!({"name": c.name, "hex": c.color.to_hex(), "color": c.color})).collect::<Vec<_>>(),
"charStyles": lib.char_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
"paraStyles": lib.para_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::<Vec<_>>(),
}))
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("My Library").to_string();
let id = s.libraries.create(&name)?;
Ok(json!({ "id": id, "name": name }))
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.rename";
let id = library_id(s, p, C)?;
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
let n = name.clone();
s.libraries.change(&id, |l| {
l.name = n;
Ok(())
})?;
Ok(json!({ "id": id, "name": name }))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let id = library_id(s, p, "library.delete")?;
s.libraries.delete(&id)?;
ok()
}
/// The library `p` names, else the current one, else a new "My Library".
fn target_library(s: &mut Session, p: &Value, cmd: &str) -> Result<String> {
if p.get("library").is_some_and(|v| !v.is_null()) || s.libraries.current().is_some() {
return library_id(s, p, cmd);
}
s.libraries.create("My Library")
}
/// `base`, or `base 2`, `base 3`… the first that `taken` doesn't hold.
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.add";
let kind = Kind::of(p, C)?;
let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
// Read what is added before a library is made for it, so a failure makes none.
let item = match kind {
Kind::Graphic => Item::Graphic(graphic_of(s, p, C)?),
Kind::FillColor | Kind::StrokeColor => Item::Color(color_of(s, p, kind == Kind::StrokeColor, C)?),
Kind::CharStyle | Kind::ParaStyle => {
let para = kind == Kind::ParaStyle;
let attrs = super::textstyles::selected_style_attrs(s, para)?.ok_or_else(|| bad(C, "select some type first"))?;
Item::Style(para, attrs)
}
};
let id = target_library(s, p, C)?;
let kind_name = str_param(p, "kind").unwrap_or_default().to_string();
let r = s.libraries.change(&id, |lib| {
let full = |n: usize| if n >= MAX_ITEMS { Err(bad(C, format!("the library holds {MAX_ITEMS} items of this kind already"))) } else { Ok(()) };
Ok(match item {
Item::Graphic(mut g) => {
full(lib.graphics.len())?;
if let Some(old) = lib.graphics.iter().find(|o| o.data == g.data) {
return Ok(json!({ "name": old.name, "id": old.id, "existing": true }));
}
let base = given.clone().unwrap_or_else(|| g.name.clone());
g.name = unique(&base, |n| lib.graphics.iter().any(|o| o.name == n));
g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n)).replace(' ', "-");
let v = json!({ "name": g.name, "id": g.id, "existing": false });
lib.graphics.push(g);
v
}
Item::Color((name, color)) => {
full(lib.colors.len())?;
if let Some(old) = lib.colors.iter().find(|o| o.color == color) {
return Ok(json!({ "name": old.name, "existing": true }));
}
let name = unique(&given.clone().unwrap_or(name), |n| lib.colors.iter().any(|o| o.name == n));
lib.colors.push(LibraryColor { name: name.clone(), color });
json!({ "name": name, "existing": false })
}
Item::Style(para, attrs) => {
let list = if para { &mut lib.para_styles } else { &mut lib.char_styles };
full(list.len())?;
if let Some(old) = list.iter().find(|o| o.attrs == attrs) {
return Ok(json!({ "name": old.name, "existing": true }));
}
let base = given.clone().unwrap_or_else(|| style_name(&attrs, para));
let name = unique(&base, |n| list.iter().any(|o| o.name == n));
list.push(TextStyleDef { name: name.clone(), attrs });
json!({ "name": name, "existing": false })
}
})
})?;
let mut r = r;
r["library"] = json!(id);
r["kind"] = json!(kind_name);
Ok(r)
}
enum Item {
Graphic(LibraryGraphic),
Color((String, Color)),
Style(bool, Map<String, Value>),
}
/// The selected objects (or `ids`) as a library graphic (its id and name set by the caller).
fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result<LibraryGraphic> {
let st = s.doc()?;
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
None => st.selection.in_paint_order(&st.doc),
};
let roots = super::edit::roots_of(&st.doc, ids);
if roots.is_empty() {
return Err(bad(cmd, "select the art to add first"));
}
let clip = Clipboard::copy(st, &roots);
let bounds = clip.bounds().ok_or_else(|| bad(cmd, "the selection draws nothing"))?;
let mut doc = clip.to_document();
let board = bounds.inflate(1.0, 1.0);
// One artboard round the art: what the thumbnail shows.
doc.artboards.truncate(1);
match doc.artboards.first_mut() {
Some(a) => a.rect = board,
None => return Err(bad(cmd, "the graphic's document has no artboard")),
}
let name = match roots.as_slice() {
[one] => st.doc.node(*one).map(|n| n.display_name()).unwrap_or_default(),
_ => String::new(),
};
let name = if name.trim().is_empty() || name.starts_with('<') { "Graphic".to_string() } else { name };
let thumbnail = super::fileio::preview_png(&doc)?.map(|b| vectorcraft_format::base64_encode(&b)).unwrap_or_default();
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&doc, false));
Ok(LibraryGraphic { id: String::new(), name, width: bounds.width(), height: bounds.height(), data, thumbnail })
}
/// The first selected object's solid fill (`stroke`: stroke) colour, else the default one, with a
/// name: its swatch's, else its hex.
fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<(String, Color)> {
// A colour given (a swatch dropped on the panel).
if let Some(c) = p.get("color").filter(|v| !v.is_null()) {
let color = color_value(c).ok_or_else(|| bad(cmd, "bad colour"))?;
return Ok((color.to_hex(), color));
}
let st = s.doc()?;
let ids = match ids_param(p, "ids") {
Some(ids) => ids,
None => st.selection.objects.clone(),
};
let from_art = ids.iter().filter_map(|id| st.doc.node(*id)).find_map(|n| {
let paint = if stroke { n.appearance.stroke().map(|x| &x.paint) } else { n.appearance.fill().map(|f| &f.paint) };
paint.cloned()
});
let paint = match from_art {
Some(p) => p,
None => {
let d = &s.paint;
if stroke { d.stroke.clone() } else { d.fill.clone() }
}
};
match paint {
Paint::Solid { color, swatch, .. } => {
let name = swatch.filter(|n| !n.is_empty()).unwrap_or_else(|| color.to_hex());
Ok((name, color))
}
Paint::None => Err(bad(cmd, if stroke { "the selection has no stroke colour" } else { "the selection has no fill colour" })),
_ => Err(bad(cmd, "only solid colours can be added (not gradients or patterns)")),
}
}
/// A character style's name from its font, style and size ("Source Sans 3 Bold 24 pt"); a
/// paragraph style's from its alignment and size too, else "Paragraph Style".
fn style_name(attrs: &Map<String, Value>, para: bool) -> String {
let s = |k: &str| attrs.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
let size = attrs.get("size").and_then(Value::as_f64).map(|v| format!("{} pt", (v * 100.0).round() / 100.0));
let words: Vec<String> = [s("font_family"), s("font_style")].into_iter().chain(size).filter(|w| !w.is_empty()).collect();
match (para, words.is_empty()) {
(false, false) => words.join(" "),
(false, true) => "Character Style".into(),
(true, _) => "Paragraph Style".into(),
}
}
/// The library item `p` names: (library id, kind).
fn item_param<'a>(s: &Session, p: &'a Value, cmd: &str) -> Result<(String, Kind, &'a str)> {
let id = library_id(s, p, cmd)?;
let kind = Kind::of(p, cmd)?;
let item = str_param(p, "item").ok_or_else(|| bad(cmd, "missing `item`"))?;
Ok((id, kind, item))
}
fn use_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.use";
// A replay (the journal) carries what the item was, so it doesn't depend on the library.
let recorded = p.get("recorded").cloned();
let (kind, data) = match recorded {
Some(r) => (Kind::of(p, C)?, r),
None => {
let (id, kind, item) = item_param(s, p, C)?;
let lib = s.libraries.get(&id).ok_or_else(|| bad(C, format!("no library `{id}`")))?;
let missing = || bad(C, format!("the library has no item `{item}`"));
let data = match kind {
Kind::Graphic => {
let g =
lib.graphics.iter().find(|g| g.id == item).or_else(|| lib.graphics.iter().find(|g| g.name == item)).ok_or_else(missing)?;
json!({ "data": g.data })
}
Kind::FillColor | Kind::StrokeColor => {
let c = lib.colors.iter().find(|c| c.name == item).ok_or_else(missing)?;
json!({ "color": c.color })
}
Kind::CharStyle | Kind::ParaStyle => {
let list = if kind == Kind::ParaStyle { &lib.para_styles } else { &lib.char_styles };
let t = list.iter().find(|t| t.name == item).ok_or_else(missing)?;
json!({ "name": t.name, "attrs": t.attrs })
}
};
s.note_journal("recorded", data.clone());
(kind, data)
}
};
match kind {
Kind::Graphic => {
let bytes = data
.get("data")
.and_then(Value::as_str)
.and_then(vectorcraft_format::base64_decode)
.ok_or_else(|| bad(C, "the graphic's data is damaged"))?;
let doc = vectorcraft_format::load(&bytes).map_err(|e| bad(C, format!("the graphic can't be read: {e}")))?;
let clip = Clipboard::from_document(&doc);
if clip.is_empty() {
return Err(bad(C, "the graphic is empty"));
}
let center = match point_param(p, "center") {
Some(c) => c,
None => s.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(),
};
let mut r = super::edit::place_clip(s, &clip, center)?;
r["kind"] = json!("graphic");
Ok(r)
}
Kind::FillColor | Kind::StrokeColor => {
let stroke = match str_param(p, "to") {
Some("fill") => false,
Some("stroke") => true,
Some(_) => return Err(bad(C, "to must be fill or stroke")),
None => kind == Kind::StrokeColor,
};
let color = data.get("color").cloned().ok_or_else(|| bad(C, "no colour"))?;
s.execute(if stroke { "paint.setStroke" } else { "paint.setFill" }, &json!({ "color": color }))?;
Ok(json!({ "kind": if stroke { "strokeColor" } else { "fillColor" } }))
}
Kind::CharStyle | Kind::ParaStyle => {
let para = kind == Kind::ParaStyle;
let name = data.get("name").and_then(Value::as_str).unwrap_or("Style").to_string();
let attrs = data.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
let doc = &s.doc()?.doc;
// The document's style of that name when it is the same; else a new one.
let same = super::textstyles::style_attrs(doc, para, &name).is_some_and(|a| a == attrs);
let style = if same { name } else { unique(&name, |n| super::textstyles::style_attrs(doc, para, n).is_some()) };
let prefix = if para { "paraStyle" } else { "charStyle" };
in_one_step(s, |s| {
if !same {
s.execute(&format!("{prefix}.new"), &json!({ "name": style, "attrs": attrs }))?;
}
s.execute(&format!("{prefix}.apply"), &json!({ "name": style }))
})?;
Ok(json!({ "kind": if para { "paraStyle" } else { "charStyle" }, "style": style }))
}
}
}
/// Run `f`'s commands as one undo step (undone together when it fails), unless an undo group is
/// already open (its own step then holds them).
fn in_one_step<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
let open = s.active().is_some_and(|st| st.undo_group.is_some());
if !open {
s.begin_undo_group();
}
let r = f(s);
if !open {
s.end_undo_group(r.is_err());
}
r
}
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "library.removeItem";
let (id, kind, item) = item_param(s, p, C)?;
let item = item.to_string();
s.libraries.change(&id, |lib| {
let before = (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len());
match kind {
Kind::Graphic => {
let at = lib.graphics.iter().position(|g| g.id == item).or_else(|| lib.graphics.iter().position(|g| g.name == item));
if let Some(i) = at {
lib.graphics.remove(i);
}
}
Kind::FillColor | Kind::StrokeColor => lib.colors.retain(|c| c.name != item),
Kind::CharStyle => lib.char_styles.retain(|t| t.name != item),
Kind::ParaStyle => lib.para_styles.retain(|t| t.name != item),
}
if before == (lib.graphics.len(), lib.colors.len(), lib.char_styles.len(), lib.para_styles.len()) {
return Err(bad(C, format!("the library has no item `{item}`")));
}
Ok(())
})?;
ok()
}
+2
View File
@@ -32,6 +32,7 @@ pub mod help;
pub(crate) mod inline;
mod layer;
mod layerpanel;
pub mod library;
pub mod links;
mod live;
pub(crate) mod maskedit;
@@ -245,6 +246,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(freeform::specs());
v.extend(flatten::specs());
v.extend(stylelib::specs());
v.extend(library::specs());
v.extend(expand::specs());
v.extend(attributes::specs());
v.extend(newart::specs());
+14 -1
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character / Paragraph",
[],
None,
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
has_doc,
set_format
),
@@ -218,6 +218,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"spaceAfter",
"hyphenate",
"mojikumi",
"kinsoku",
"direction",
"leadingModel",
"charAlign",
@@ -245,6 +246,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "`mojikumi` must be \"none\" or \"lineEndHalf\"")),
}),
};
let kinsoku = match p.get("kinsoku") {
None => None,
Some(v) => Some(match v.as_str() {
Some("none") => vectorcraft_doc::Kinsoku::None,
Some("hard") => vectorcraft_doc::Kinsoku::Hard,
Some("soft") => vectorcraft_doc::Kinsoku::Soft,
_ => return Err(bad(C, "`kinsoku` must be \"none\", \"hard\" or \"soft\"")),
}),
};
let direction = match p.get("direction") {
None => None,
Some(v) => Some(match v.as_str() {
@@ -333,6 +343,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = mojikumi {
para.mojikumi = v;
}
if let Some(v) = kinsoku {
para.kinsoku = v;
}
if let Some(v) = direction {
para.direction = v;
}
+3
View File
@@ -109,6 +109,9 @@ fn to_postscript(p: &Value) -> Result<bool> {
}
fn print(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = str_param(p, "path") {
super::fileio::check_not_lossy_overwrite(s.doc()?, path, p, PRINT)?;
}
let postscript = to_postscript(p)?;
let doc = &s.doc()?.doc;
let mut set = settings(PRINT, doc, p)?;
+14 -1
View File
@@ -523,6 +523,10 @@ struct Meta {
format: String,
/// When the copy was written (Unix seconds; none without a clock).
saved: Option<i64>,
/// The file it was read from when that import left things out of the document, and what
/// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it.
imported_from: Option<String>,
import_losses: Vec<String>,
}
fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta {
@@ -651,7 +655,14 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
modified: None,
};
let save = fileio::job_for(s, st, plan)?;
let meta = Meta { title: title.clone(), path: st.path.clone(), format: st.format.to_string(), saved };
let meta = Meta {
title: title.clone(),
path: st.path.clone(),
format: st.format.to_string(),
saved,
imported_from: st.imported_from.clone(),
import_losses: st.import_losses.clone(),
};
out.push(RecoveryJob { uid: st.uid, file, title, meta, save });
}
Ok((out, skipped))
@@ -839,6 +850,8 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
let index = s.add_document(doc, meta.path.clone());
let st = s.doc_mut()?;
st.recovered = true;
st.imported_from = meta.imported_from.clone();
st.import_losses = meta.import_losses.clone();
st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft");
st.mark_unsaved();
// The copy stays until the document is saved or closed.
+11
View File
@@ -241,6 +241,17 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result<Option<Map<Stri
}))
}
/// The selected text's character (`para`: paragraph) attributes, every one of them: what a
/// library keeps of a text style. `None` without text selected.
pub(crate) fn selected_style_attrs(s: &Session, para: bool) -> Result<Option<Map<String, Value>>> {
selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char })
}
/// Whether the document has a character (`para`: paragraph) style `name`, and its attributes.
pub(crate) fn style_attrs(d: &Document, para: bool, name: &str) -> Option<Map<String, Value>> {
if para { Kind::Para } else { Kind::Char }.attrs(d, name)
}
fn name_param<'a>(p: &'a Value, kind: Kind, verb: &str) -> Result<&'a str> {
str_param(p, "name").ok_or_else(|| bad(&kind.cmd(verb), "missing `name`"))
}
+30
View File
@@ -642,6 +642,36 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
mod area_tests {
use super::*;
#[test]
fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() {
use vectorcraft_doc::Kinsoku;
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "あカッ", "area": {"width": 40, "height": 100}})).unwrap()["id"]
.as_u64()
.unwrap();
let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.para.clone(),
_ => panic!("text"),
};
// New type: Hard, today's set.
assert_eq!(para(&s).kinsoku, Kinsoku::Hard);
assert!(serde_json::to_value(para(&s)).unwrap().get("kinsoku").is_none());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
assert!(s.execute("text.setFormat", &json!({"kinsoku": "strict"})).is_err());
s.execute("text.setFormat", &json!({"kinsoku": "soft"})).unwrap();
assert_eq!(para(&s).kinsoku, Kinsoku::Soft);
assert_eq!(serde_json::to_value(para(&s)).unwrap()["kinsoku"], "soft");
s.execute("text.setFormat", &json!({"kinsoku": "none"})).unwrap();
assert_eq!(para(&s).kinsoku, Kinsoku::None);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(para(&s).kinsoku, Kinsoku::Hard);
// Documents from before it read as Hard.
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
assert_eq!(old.kinsoku, Kinsoku::Hard);
}
#[test]
fn burasagari_is_set_per_paragraph_and_saved_only_when_on() {
use vectorcraft_doc::Burasagari;
+14
View File
@@ -155,6 +155,11 @@ pub struct DocState {
pub print_tiling: bool,
/// The rows open in the Layers panel (view state: not undoable; native files keep it).
pub layers_open: OpenRows,
/// The file this document was read from, and what reading it left out (hidden text, art or
/// layers it could not read): writing the document over that file would lose those for good,
/// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved.
pub imported_from: Option<String>,
pub import_losses: Vec<String>,
/// Transform Again after a perspective move or scale (Perspective Selection tool): the
/// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
pub last_perspective: Option<Value>,
@@ -243,6 +248,8 @@ impl DocState {
recovery: None,
print_tiling: false,
layers_open,
imported_from: None,
import_losses: vec![],
last_perspective: None,
}
}
@@ -822,6 +829,8 @@ pub struct Session {
pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>,
/// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved.
pub style_libraries: cmd::stylelib::Libraries,
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
pub libraries: cmd::library::Libraries,
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
pub recent_urls: Vec<String>,
/// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame;
@@ -892,6 +901,7 @@ impl Session {
swatch_libraries: Default::default(),
freeform_point: None,
style_libraries: Default::default(),
libraries: Default::default(),
recent_urls: vec![],
ui_language: None,
journal_note: Default::default(),
@@ -1016,6 +1026,8 @@ impl Session {
st.format = old.format;
st.save_options = old.save_options.clone();
st.converted = old.converted;
st.imported_from = old.imported_from.clone();
st.import_losses = old.import_losses.clone();
st.view = old.view.clone();
st.layers_open = old.layers_open.clone();
let old = std::mem::replace(&mut self.docs[index], st);
@@ -1466,6 +1478,8 @@ mod tests_layerclip;
#[cfg(test)]
mod tests_layers;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_linked_stops;
#[cfg(test)]
mod tests_links;
+23
View File
@@ -209,3 +209,26 @@ fn rotated_box_rect_matches_page_bounds_when_upright() {
sel(&mut s, &[a]);
assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(10.0, 20.0, 40.0, 60.0)));
}
/// The Selection tool says when a drag moves, scales or rotates the selection (the canvas hides the
/// bounding box meanwhile, #712), and not on a press that hasn't moved or a marquee.
#[test]
fn the_selection_tool_reports_a_transforming_drag() {
let mut s = session();
let id = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y).with_mods(Mods::default());
s.pointer(&ev(PointerKind::Down, 150.0, 125.0), v).unwrap();
assert!(!s.tool_transforming(), "a press that hasn't moved");
s.pointer(&ev(PointerKind::Drag, 190.0, 160.0), v).unwrap();
assert!(s.tool_transforming(), "moving");
s.pointer(&ev(PointerKind::Up, 190.0, 160.0), v).unwrap();
assert!(!s.tool_transforming());
// A marquee on empty canvas isn't a transform.
s.pointer(&ev(PointerKind::Down, 500.0, 400.0), v).unwrap();
s.pointer(&ev(PointerKind::Drag, 550.0, 450.0), v).unwrap();
assert!(s.tool_busy() && !s.tool_transforming());
s.pointer(&ev(PointerKind::Up, 550.0, 450.0), v).unwrap();
}
+49 -2
View File
@@ -750,14 +750,61 @@ fn image_trace_presets_query_and_errors() {
let mut s = session();
let r = s.execute("imageTrace.presets", &json!({})).unwrap();
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!(names.len(), 12);
assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray"));
assert_eq!(names.len(), 13);
assert!(names.contains(&"Technical Drawing") && names.contains(&"Shades of Gray") && names.contains(&"Flat Logo"));
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
assert!(s.execute("imageTrace.make", &json!({"id": a.0})).is_err(), "not an image");
assert!(find_command("imageTrace.make").unwrap().menu == ["Object", "Image Trace"]);
assert!(find_command("livePaint.make").unwrap().menu == ["Object", "Live Paint"]);
}
/// A soft-edged red disc (r = 30 px at (50.3, 49.6)) on transparency, placed at (50, 50) scaled ×2.
fn soft_logo_doc(s: &mut Session) -> NodeId {
let r = vectorcraft_trace::Raster::from_fn(100, 100, |x, y| {
let d = (x as f64 + 0.5 - 50.3).hypot(y as f64 + 0.5 - 49.6);
let cover = (0.5 + (30.0 - d) * 0.7).clamp(0.0, 1.0);
[200, 40, 60, (cover * 255.0).round() as u8]
});
add_image(s, &r, Affine::translate((50.0, 50.0)) * Affine::scale(2.0))
}
#[test]
fn image_trace_flat_logo_preset_makes_a_true_circle() {
let mut s = session();
soft_logo_doc(&mut s);
let r = s.execute("imageTrace.make", &json!({"preset": "Flat Logo"})).unwrap();
assert_eq!((r["paths"].as_u64(), r["colors"].as_u64()), (Some(1), Some(1)));
assert!(r["anchors"].as_u64().unwrap() <= 6, "a circle is four anchors: {r}");
let g = NodeId(r["id"].as_u64().unwrap());
let n = node(&s, g);
let t = n.trace.clone().expect("settings stored");
assert_eq!((t["preset"].as_str(), t["params"]["mode"].as_str()), (Some("Flat Logo"), Some("logo")));
let paths = traced_paths(&n);
let (p, rule) = outline(&paths[0]);
let want = std::f64::consts::PI * 60.0 * 60.0;
let got = vectorcraft_pathops::area(&p, rule);
assert!((got - want).abs() / want < 0.01, "area {got} vs {want}");
let bb = p.bounds().unwrap();
// The disc is at (50.3, 49.6) in a x2 image placed at (50, 50): centre (150.6, 149.2).
assert!((bb.center().x - 150.6).abs() < 0.3 && (bb.center().y - 149.2).abs() < 0.3, "{bb:?}");
}
#[test]
fn image_trace_flat_logo_refuses_a_gradient_and_a_bad_palette() {
let mut s = session();
let grad = vectorcraft_trace::Raster::from_fn(64, 32, |x, y| [(x * 4) as u8, 255 - (x * 4) as u8, (y * 6) as u8, 255]);
add_image(&mut s, &grad, Affine::IDENTITY);
let e = s.execute("imageTrace.make", &json!({"preset": "Flat Logo"})).unwrap_err().to_string();
assert!(e.contains("flat-colour") && e.contains("Color mode"), "{e}");
let mut s = session();
soft_logo_doc(&mut s);
let e = s.execute("imageTrace.make", &json!({"preset": "Flat Logo", "params": {"logoColors": ["crimson"]}})).unwrap_err().to_string();
assert!(e.contains("#rrggbb"), "{e}");
// The named colours are used as given.
let r = s.execute("imageTrace.makeAndExpand", &json!({"preset": "Flat Logo", "params": {"logoColors": ["#c8283c"]}})).unwrap();
assert_eq!(r["colors"], 1);
}
#[test]
fn image_trace_object_remembers_its_settings() {
let mut s = session();
+163
View File
@@ -0,0 +1,163 @@
//! The Libraries panel's libraries (#745): making, renaming and deleting them, adding graphics,
//! colours and text styles from the selection, using them in another document, and keeping them
//! in files across sessions.
use serde_json::{Value, json};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len())
}
fn hex_of(s: &Session, id: &Value, stroke: bool) -> String {
let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
let paint = if stroke { n.appearance.stroke().unwrap().paint.clone() } else { n.appearance.fill().unwrap().paint.clone() };
paint.color().unwrap().to_hex()
}
#[test]
fn libraries_are_made_renamed_and_deleted() {
let mut s = session();
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"], json!([]));
let id = run(&mut s, "library.create", json!({"name": "Brand"}))["id"].as_str().unwrap().to_string();
let other = run(&mut s, "library.create", json!({"name": "Brand"}))["id"].as_str().unwrap().to_string();
assert_ne!(id, other, "two libraries of one name are two files");
run(&mut s, "library.rename", json!({"library": other, "name": "Client"}));
let list = run(&mut s, "library.list", json!({}));
let names: Vec<&str> = list["libraries"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Brand", "Client"]);
assert!(list["folder"].is_null(), "no folder: libraries last for the session");
run(&mut s, "library.delete", json!({"library": "client"}));
assert_eq!(run(&mut s, "library.list", json!({}))["libraries"].as_array().unwrap().len(), 1);
assert!(s.execute("library.get", &json!({"library": "Client"})).is_err());
}
#[test]
fn a_graphic_goes_into_a_library_and_comes_out_in_another_document() {
let mut s = session();
let id = run(&mut s, "shape.ellipse", json!({"x": 10, "y": 10, "width": 40, "height": 30}))["id"].clone();
run(&mut s, "paint.setFill", json!({"color": "#cc3300", "ids": [id]}));
run(&mut s, "select.set", json!({"ids": [id]}));
// With no library yet, adding makes "My Library".
let added = run(&mut s, "library.add", json!({"kind": "graphic"}));
assert_eq!(added["existing"], false);
let lib = added["library"].as_str().unwrap().to_string();
let item = added["id"].as_str().unwrap().to_string();
let got = run(&mut s, "library.get", json!({"library": lib}));
assert_eq!(got["name"], "My Library");
let g = &got["graphics"][0];
// Its size as it draws: the 1 pt stroke included.
assert!((g["width"].as_f64().unwrap() - 41.0).abs() < 1e-6 && (g["height"].as_f64().unwrap() - 31.0).abs() < 1e-6, "{g}");
assert!(!g["thumbnail"].as_str().unwrap().is_empty(), "a thumbnail of the art");
// The same art again is the item already there.
assert_eq!(run(&mut s, "library.add", json!({"kind": "graphic"}))["existing"], true);
// Placed in another document, centred where asked, selected, one undo step.
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
let placed = run(&mut s, "library.use", json!({"library": lib, "kind": "graphic", "item": item, "center": [150, 100]}));
let new_id = placed["ids"][0].clone();
let st = s.doc().unwrap();
let b = st.doc.node(NodeId(new_id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap();
assert!((b.center().x - 150.0).abs() < 1e-6 && (b.center().y - 100.0).abs() < 1e-6, "{b:?}");
assert_eq!(st.selection.objects, vec![NodeId(new_id.as_u64().unwrap())]);
assert_eq!(hex_of(&s, &new_id, false), "#cc3300");
assert_eq!(objects(&s), 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(objects(&s), 0);
// A graphic is removed by id.
run(&mut s, "library.removeItem", json!({"library": lib, "kind": "graphic", "item": item}));
assert!(s.execute("library.use", &json!({"library": lib, "kind": "graphic", "item": item})).is_err());
}
#[test]
fn colours_come_from_the_selection_and_paint_it() {
let mut s = session();
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone();
run(&mut s, "paint.setFill", json!({"color": "#123456", "ids": [a]}));
run(&mut s, "paint.setStroke", json!({"color": "#abcdef", "ids": [a]}));
run(&mut s, "select.set", json!({"ids": [a]}));
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["name"], "#123456");
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "strokeColor"}))["name"], "#abcdef");
assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["existing"], true);
let b = run(&mut s, "shape.rectangle", json!({"x": 50, "y": 0, "width": 20, "height": 20}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [b]}));
run(&mut s, "library.use", json!({"library": lib, "kind": "fillColor", "item": "#123456"}));
run(&mut s, "library.use", json!({"library": lib, "kind": "fillColor", "item": "#123456", "to": "stroke"}));
assert_eq!(hex_of(&s, &b, false), "#123456");
assert_eq!(hex_of(&s, &b, true), "#123456");
assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillColor", "item": "#nope"})).is_err());
}
#[test]
fn text_styles_come_from_the_selected_type_and_apply_in_one_step() {
let mut s = session();
let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string();
assert!(s.execute("library.add", &json!({"library": lib, "kind": "charStyle"})).is_err(), "no type selected");
let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Heading", "size": 31}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [t]}));
let name = run(&mut s, "library.add", json!({"library": lib, "kind": "charStyle"}))["name"].as_str().unwrap().to_string();
assert!(name.ends_with("31 pt"), "{name}");
run(&mut s, "library.add", json!({"library": lib, "kind": "paraStyle"}));
// Another document: the style is added and applied, and one undo takes both back.
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
let u = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [u]}));
let undo = s.doc().unwrap().history.undo.len();
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name}));
assert_eq!(r["style"], name);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
let size = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(u.as_u64().unwrap())).unwrap().kind {
vectorcraft_doc::NodeKind::Text(t) => t.runs[0].style.size,
_ => 0.0,
};
assert_eq!(size(&s), 31.0);
run(&mut s, "edit.undo", json!({}));
assert_eq!(size(&s), 9.0);
assert!(s.doc().unwrap().doc.char_styles.iter().all(|c| c.name != name), "the style went with the undo");
// A style of that name with other attributes in the document: the library's comes in numbered.
run(&mut s, "charStyle.new", json!({"name": name, "attrs": {"size": 5}}));
let r = run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name}));
assert_eq!(r["style"], format!("{name} 2"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn libraries_are_kept_in_files_across_sessions() {
let dir = std::env::temp_dir().join(format!("vc-libraries-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let folder = dir.to_string_lossy().to_string();
let mut s = session();
s.libraries.set_dir(Some(folder.clone()));
let lib = run(&mut s, "library.create", json!({"name": "Kept"}))["id"].as_str().unwrap().to_string();
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].clone();
run(&mut s, "select.set", json!({"ids": [id]}));
run(&mut s, "library.add", json!({"library": lib, "kind": "graphic", "name": "Square"}));
run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}));
let mut t = session();
t.libraries.set_dir(Some(folder.clone()));
let got = run(&mut t, "library.get", json!({"library": "Kept"}));
assert_eq!(got["graphics"][0]["name"], "Square");
assert_eq!(got["colors"].as_array().unwrap().len(), 1);
run(&mut t, "library.delete", json!({"library": "Kept"}));
let mut u = session();
u.libraries.set_dir(Some(folder));
assert_eq!(run(&mut u, "library.list", json!({}))["libraries"], json!([]), "deleting removes the file");
// A damaged file is skipped.
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Bad.vclibrary"), b"{not json").unwrap();
let mut v = session();
v.libraries.set_dir(Some(dir.to_string_lossy().to_string()));
assert_eq!(run(&mut v, "library.list", json!({}))["libraries"], json!([]));
let _ = std::fs::remove_dir_all(&dir);
}
+16
View File
@@ -122,6 +122,22 @@ fn a_crash_leaves_copies_that_restore_modified_and_titled() {
assert!(s.execute("file.recovery.restore", &json!({"file": format!("{mine}/Poster-1")})).is_err(), "an open document's copy");
}
#[test]
fn a_restored_copy_still_refuses_to_replace_the_file_an_import_left_things_out_of() {
let (mut s, store) = session();
s.doc_mut().unwrap().path = Some("/art/Poster.eps".into());
s.doc_mut().unwrap().imported_from = Some("/art/Poster.eps".into());
s.doc_mut().unwrap().import_losses = vec!["hidden layers have art this can't read".into()];
s.execute("file.recovery.save", &json!({})).unwrap();
drop(s);
let mut s = relaunch(&store);
s.execute("file.recovery.restore", &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.imported_from.as_deref(), Some("/art/Poster.eps"));
assert_eq!(st.import_losses, ["hidden layers have art this can't read"]);
}
#[test]
fn another_running_apps_copies_are_never_offered() {
let store = Arc::new(MemoryStore::default());
+100
View File
@@ -316,6 +316,106 @@ fn artboard_move_copy_reports_the_copies_and_follows_move_art() {
assert_eq!(ids.len(), 3, "every artboard has an id of its own");
}
fn stroke_width(s: &Session, id: NodeId) -> f64 {
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width
}
fn guide_positions(s: &Session) -> Vec<f64> {
s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect()
}
/// Scale Artwork with Artboard (#602): `artboard.setProps {scaleArt}` takes the art fully inside the
/// artboard and its guides from the old rectangle onto the new one, each side by its own ratio, in
/// one undo step; art partly outside and canvas guides stay.
#[test]
fn artboard_set_props_scales_the_art_and_guides_with_the_artboard() {
let mut s = session();
let inside = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
let partly = rect(&mut s, 700.0, 500.0, 200.0, 200.0);
for (vertical, pos, artboard) in [(true, 400.0, json!(0)), (false, 300.0, json!(0)), (true, 400.0, json!(null))] {
s.execute("guide.add", &json!({"vertical": vertical, "pos": pos, "artboard": artboard})).unwrap();
}
let before = s.doc().unwrap().doc.clone();
// Half the size: half the art, half the stroke (Scale Strokes & Effects).
let out = s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true, "strokes": true})).unwrap();
assert_eq!(out["scaled"], json!([inside.0]));
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 400.0, 300.0));
assert_eq!(bounds(&s, inside), Rect::new(50.0, 50.0, 150.0, 100.0));
assert_eq!(stroke_width(&s, inside), 2.0);
assert_eq!(bounds(&s, partly), Rect::new(700.0, 500.0, 900.0, 700.0), "art partly outside stays");
assert_eq!(guide_positions(&s), vec![200.0, 150.0, 400.0], "the artboard's guides scale, the canvas guide stays");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before, "one undo step");
// Moved and stretched (left edge to -100, twice as wide), strokes kept: the art follows.
s.execute("artboard.setProps", &json!({"index": 0, "x": -100, "width": 1600, "scaleArt": true, "strokes": false})).unwrap();
assert_eq!(bounds(&s, inside), Rect::new(100.0, 100.0, 500.0, 200.0));
assert_eq!(stroke_width(&s, inside), 4.0);
assert_eq!(guide_positions(&s), vec![700.0, 300.0, 400.0]);
}
/// Without `scaleArt` a resize leaves the art and guides alone (and returns nothing, as before);
/// with it, a pure move scales nothing (guides move along, as they always have).
#[test]
fn artboard_set_props_scales_art_only_when_asked_and_resized() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 400, "artboard": 0})).unwrap();
assert_eq!(s.execute("artboard.setProps", &json!({"index": 0, "width": 400})).unwrap(), Value::Null);
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
assert_eq!(guide_positions(&s), vec![400.0]);
let out = s.execute("artboard.setProps", &json!({"index": 0, "x": 50, "scaleArt": true})).unwrap();
assert_eq!(out["scaled"], json!([]));
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
assert_eq!(guide_positions(&s), vec![450.0]);
assert!(s.execute("artboard.setProps", &json!({"index": 9, "width": 10, "scaleArt": true})).is_err());
}
/// Locked and hidden art scales only with Move Locked and Hidden Artwork with Artboard, as it
/// moves only with it.
#[test]
fn scale_artwork_with_artboard_leaves_locked_art_unless_the_preference_says() {
let mut s = session();
let locked = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("object.lock", &json!({})).unwrap();
let scaled = |s: &mut Session| {
let out = s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
out["scaled"].clone()
};
assert_eq!(scaled(&mut s), json!([]));
s.execute("prefs.set", &json!({"key": "moveLockedWithArtboard", "value": true})).unwrap();
assert_eq!(scaled(&mut s), json!([locked.0]));
// The choice is journaled: replayed with the preference off, the same art scales.
s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true})).unwrap();
assert_eq!(s.journal.last().unwrap().1["lockedAndHidden"], true);
let (id, p) = s.journal.last().unwrap().clone();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("prefs.set", &json!({"key": "moveLockedWithArtboard", "value": false})).unwrap();
assert_eq!(s.execute(&id, &p).unwrap()["scaled"], json!([locked.0]));
}
/// The Artboard tool with its `scaleArt` option on resizes proportionally and the art scales with
/// the artboard, in one undo step.
#[test]
fn artboard_tool_scales_art_with_the_artboard() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
let before = s.doc().unwrap().doc.clone();
s.set_tool_options(Some("artboard"), json!({"scaleArt": true}).as_object().unwrap());
// The right handle 100 pt out: 9/8 as wide, and as tall about the middle of the left side.
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, -37.5, 900.0, 637.5));
assert_eq!(bounds(&s, r), Rect::new(112.5, 75.0, 337.5, 187.5));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before, "one undo step");
// Off again (the option is remembered like Move Artwork with Artboard): the art stays.
s.set_tool_options(Some("artboard"), json!({"scaleArt": false}).as_object().unwrap());
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 900.0, 600.0));
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
}
#[test]
fn magic_wand_selects_same_fill() {
let mut s = session();
+5
View File
@@ -358,6 +358,11 @@ impl Session {
self.tool.busy()
}
/// Is the active tool moving, scaling or rotating the selection with a drag?
pub fn tool_transforming(&self) -> bool {
self.tool.transforming()
}
/// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)?
pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool {
self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key))
+2
View File
@@ -272,6 +272,8 @@ fn structured_junk() {
("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})),
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})),
("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})),
("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})),
("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})),
("artboard.new", json!({"width": 0, "height": -1})),
("artboard.delete", json!({"index": 0})),
("artboard.move", json!({"index": 0, "dx": 1e308, "dy": 0, "moveArt": true})),
+301 -1
View File
@@ -4,7 +4,8 @@
//! must load as an error or as a document that then renders and exports, without a panic; nor may
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
//! of every kind with random contents) opened, placed or pasted, nor EPS and PostScript files
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor Affinity documents
//! (damaged ones, hostile programs) read by the PostScript interpreter, nor the editing data of
//! Illustrator EPS and `.ai` files (the layers they carry, damaged or hostile), nor Affinity documents
//! (mutated object streams, archives and indexed PNG previews, hostile image dimensions).
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
@@ -1584,3 +1585,302 @@ proptest! {
perspective_presets("mutated perspective presets", &mutate_text(&text, cut, &edits))?;
}
}
// ---------- the editing data of Illustrator files ----------
/// `data` as ASCII85, up to the `~>`.
fn ascii85(data: &[u8]) -> String {
let mut out = String::new();
for chunk in data.chunks(4) {
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
let mut d = [0u8; 5];
for c in d.iter_mut().rev() {
*c = (x % 85) as u8 + b'!';
x /= 85;
}
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
}
out.push_str("~>");
out
}
/// The editing data of a file with every sort of object the layers reader knows, and some it doesn't.
fn editing_text() -> String {
let square = |x: u32| format!("0 0 1 0 k\n{x} 10 m\n{} 10 L\n{} 14 L\n{x} 14 L\nf", x + 4, x + 4);
let body = [
"u".to_string(),
square(10),
"U".into(),
"*u".into(),
square(20),
square(30),
"*U".into(),
"q".into(),
square(40),
"50 10 m 60 10 L 60 20 L 50 20 L h W f".into(),
"Q".into(),
"1 Xw".into(),
"u".into(),
"/AI11Text :\n0 /FreeUndo ,\n;".into(),
"U".into(),
"0 Xw".into(),
"1 0 0 0 1 0 Bg".into(),
"0 1 w 2 J 0 j 4 M [3 2]0 d 1 D".into(),
]
.join("\n");
let layer = |name: &str, visible: u8, body: &str| {
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
};
format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n",
layer("One", 1, &format!("{body}\n{}", layer("Sub", 1, &square(70)))),
layer("Two", 0, &square(80))
)
}
fn zstd(text: &str) -> Vec<u8> {
ruzstd::encoding::compress_to_vec(text.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest)
}
/// An EPS drawing a square whose private data holds `editing`.
fn editing_eps(editing: &str) -> Vec<u8> {
let lines: Vec<String> = ascii85(&zstd(editing)).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 1 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto closepath fill\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n",
lines.join("\n")
)
.into_bytes()
}
/// The text document of a story of centred type with two styles, as an editing copy keeps it.
fn text_document_text() -> String {
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 3 4 ] >> >> >> ] >> >>\n\
/1 << /1 [ << /0 << /0 (Hi\\rthere\\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 2 >> /6 0 >> >> /1 9 >> ] >> \
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 9 >> ] >> >> \
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8200.0 8180.0 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
<< /99 /S /0 << /0 [ -5.0 0.0 ] >> /15 << /0 3 >> >> ] >> << /99 /L /0 << /0 [ 0.0 16.8 ] >> /6 [ << /99 /S /0 << /0 [ -9.0 0.0 ] >> /15 << /0 6 >> >> ] >> ] >> ] >> ] >> ] >> ] >> >> ] /2 << /1 12.0 >> >>\n"
.to_string()
}
/// An EPS with a hidden text object whose story is in `document`.
fn text_eps(document: &str) -> Vec<u8> {
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
let editing = format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
%AI5_BeginLayer\n0 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Spare) Ln\n/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nLB\n%AI5_EndLayer--\n\
%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
lines.join("\n")
);
editing_eps(&editing)
}
proptest! {
#![proptest_config(config())]
/// An Illustrator EPS whose text document is damaged or hostile: read as its type, or without it.
#[test]
fn eps_text_document_never_panics(cut in 0usize..1_500, edits in prop::collection::vec((0usize..1_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '<', '>', '\\', 'e', '1'])), 0..12)) {
let text = mutate_text(&text_document_text(), cut, &edits);
let bytes = text_eps(&text);
survive("mutated EPS text document", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
}
/// An Illustrator EPS whose editing data is damaged or hostile: read as its layers, or as its page.
#[test]
fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12)) {
let text = mutate_text(&editing_text(), cut, &edits);
let bytes = editing_eps(&text);
survive("mutated EPS editing data", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
}
/// A `.ai` whose editing data is damaged or hostile.
#[test]
fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::<u8>()), 0..4)) {
let text = mutate_text(&editing_text(), cut, &edits);
let mut private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
for (at, b) in damage {
let n = private.len();
if let Some(x) = private.get_mut(at % n) {
*x = b;
}
}
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![]).0))?;
}
}
/// The operators, operands and section comments of hostile editing data.
fn arb_ai_token() -> impl Strategy<Value = String> {
prop_oneof![
arb_num(),
prop::sample::select(vec![
"m",
"l",
"L",
"c",
"C",
"v",
"V",
"y",
"Y",
"h",
"H",
"N",
"n",
"F",
"f",
"S",
"s",
"B",
"b",
"W",
"(b) *",
"u",
"U",
"*u",
"*U",
"q",
"Q",
"LB",
"Lb",
"Ln",
"(name) Ln",
"g",
"G",
"k",
"K",
"x",
"X",
"Xa",
"XA",
"Xx",
"XX",
"(Ink) 0.5 x",
"O",
"R",
"XR",
"w",
"J",
"j",
"M",
"d",
"[6 3] 0 d",
"[]0 d",
"Xy",
"2 0.5 1 1 1 Xy",
"99 -4 Xy",
"Xw",
"1 Xw",
"A",
"1 A",
"Ae",
"XW",
"1 (style) XW",
"9 () XW",
"Bd",
"(G) 1 3 Bd",
"Bs",
"0 0 0 0 1 0 0 2 1 6 50 0 Bs",
"BD",
"Bb",
"1 Bb",
"BB",
"2 BB",
"1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg",
"Bg",
"Bm",
"1e308 0 0 1e-308 0 0 Bm",
"Bh",
"XN",
"/DeviceCMYK XN",
"/DeviceGray XN",
"[ 1 0 0 1 0 0 ] 0 0 2 2 2 2 8 3 1 0 1 0",
"[ 1 0 0 1 0 0 ] 0 0 99999 99999 99999 99999 1 1 0 0 0 0",
"[",
"]",
"/Name",
"(text)",
"<ff00>",
":",
";",
",",
"/ArtDictionary :",
"/XMLUID : (A_x41_) ; (AI10_ArtUID) ,",
"(n) /String (AIArtName) ,",
"/Document :",
"/Array :",
"/Dictionary :",
"0 0 /RealPoint (PositionPoint1) ,",
"/AI11Text :",
"0 /StoryIndex ,",
"/SymbolInstance :",
"/Binary : /ASCII85Decode ,",
"~>",
"p",
"To",
"frobnicate",
"\n%AI5_BeginLayer\n",
"\n%AI5_EndLayer--\n",
"\n%_",
"\n%AI5_BeginRaster\n",
"\n%AI5_EndRaster\n",
"\n%%BeginData: 12\rXI\n",
"\n%%EndData\n",
"\n%AI5_BeginGradient: (G)\n",
"\n%AI14_BeginSymbol\n",
"\n%AI10_EndSymbol\n",
"\n%AI17_Begin_Content_if_version_gt:24 4\n",
"\n%AI17_Alternate_Content\n",
"\n%AI17_End_Versioned_Content\n",
"\n%AI3_Cropmarks: 0 0 1e308 -1e308\n",
"\n%AI5_ArtSize: 0 0\n",
"\n%AI9_ColorModel: 2\n",
"\n%AI5_BeginPlace\n",
"\n%AI26_BeginPlacedObjectPreview\n",
"\n%%PageTrailer\n",
])
.prop_map(str::to_string),
]
}
/// The testkit's sample editing data, compressed as older `.ai` files compress it.
fn ai_sample_compressed() -> Vec<u8> {
use std::io::Write as _;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(&vectorcraft_testkit::ai::sample_data()).unwrap();
[&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat()
}
proptest! {
#![proptest_config(config())]
/// Hostile editing data in Illustrator EPS and `.ai` files: operators in any order with any
/// operands, sections and dictionaries left open, images of any size.
#[test]
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) {
use vectorcraft_testkit::ai;
let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" ")));
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![]).0))?;
let eps = ai::eps(data.as_bytes(), ai::page_ps());
survive("hostile editing data in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
}
/// The testkit's sample damaged, as it is and compressed, in EPS and `.ai` files.
#[test]
fn ai_mutated_sample_never_panics(cut in 0usize..4_000, edits in prop::collection::vec((0usize..4_000, any::<u8>()), 0..12)) {
use vectorcraft_testkit::ai;
static PLAIN: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
static PACKED: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
for (what, sample) in [("editing data", PLAIN.get_or_init(ai::sample_data)), ("compressed editing data", PACKED.get_or_init(ai_sample_compressed))] {
let mut bytes = sample.clone();
for &(at, b) in &edits {
let n = bytes.len();
bytes[at % n] = b;
}
bytes.truncate(cut.max(4));
let file = ai::ai(&bytes, ai::page_pdf());
survive(what, || vectorcraft_engine::cmd::fileio::load("x.ai", &file).ok().map(|l| l.doc))?;
let file = ai::eps(&bytes, ai::page_ps());
survive(what, || vectorcraft_engine::cmd::fileio::load("x.eps", &file).ok().map(|l| l.doc))?;
}
}
}
+110
View File
@@ -0,0 +1,110 @@
//! A file whose import left something out isn't written over silently: its hidden text, art or
//! layers would be gone for good.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::json;
use vectorcraft_engine::Session;
fn ascii85(data: &[u8]) -> String {
let mut out = String::new();
for chunk in data.chunks(4) {
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
let mut d = [0u8; 5];
for c in d.iter_mut().rev() {
*c = (x % 85) as u8 + b'!';
x /= 85;
}
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
}
out.push_str("~>");
out
}
/// A square drawn with an operator the layers' reader doesn't know and flat on the page: the file
/// comes in as its page, and its hidden layer is left out.
fn lossy_eps() -> Vec<u8> {
let layer = |name: &str, visible: u8, body: &str| {
format!("%AI5_BeginLayer\n{visible} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n")
};
let editing = format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n{}{}%%Trailer\n",
layer("Art", 1, "1 0 0 0 1 0 Zq\n0 0 1 0 k\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"),
layer("Hidden", 0, "0 0 1 0 k\n50 50 m\n54 50 L\n54 54 L\n50 54 L\nf"),
);
let packed = ruzstd::encoding::compress_to_vec(editing.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest);
let lines: Vec<String> = ascii85(&packed).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 1 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n",
lines.join("\n")
)
.into_bytes()
}
fn dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vc-lossy-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_file_the_import_left_things_out_of_is_not_written_over_unasked() {
let d = dir("a");
let src = d.join("art.eps");
let original = lossy_eps();
std::fs::write(&src, &original).unwrap();
let mut s = Session::new();
let opened = s.execute("document.open", &json!({ "path": src.to_string_lossy() })).unwrap();
assert!(opened["warnings"].to_string().contains("layers weren't read"), "{opened}");
// Over the same file: refused, and the file is as it was.
let e = s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps" })).unwrap_err().to_string();
assert!(e.contains("left things out") && e.contains("acknowledgeLoss"), "{e}");
assert_eq!(std::fs::read(&src).unwrap(), original);
// However the path is spelled.
let spelled = d.join(".").join("art.eps");
assert!(s.execute("document.export", &json!({ "path": spelled.to_string_lossy(), "format": "eps" })).is_err());
// Another name is fine, and so is replacing the file when asked to.
let other = d.join("art copy.eps");
s.execute("document.export", &json!({ "path": other.to_string_lossy(), "format": "eps" })).unwrap();
assert!(other.exists());
s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps", "acknowledgeLoss": true })).unwrap();
assert_ne!(std::fs::read(&src).unwrap(), original);
std::fs::remove_dir_all(d).ok();
}
#[test]
fn a_file_the_import_read_whole_is_written_over_as_before() {
let d = dir("b");
let src = d.join("plain.eps");
std::fs::write(&src, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 1 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto closepath fill\nshowpage\n%%EOF\n").unwrap();
let mut s = Session::new();
s.execute("document.open", &json!({ "path": src.to_string_lossy() })).unwrap();
s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps" })).unwrap();
std::fs::remove_dir_all(d).ok();
}
#[test]
fn the_other_ways_of_writing_a_file_ask_too() {
let d = dir("c");
let src = d.join("art.eps");
let original = lossy_eps();
std::fs::write(&src, &original).unwrap();
let mut s = Session::new();
s.execute("document.open", &json!({ "path": src.to_string_lossy() })).unwrap();
s.execute("select.all", &json!({})).unwrap();
for (cmd, params) in [
("document.exportPdf", json!({})),
("document.exportSelection", json!({ "format": "eps" })),
("document.exportForOffice", json!({})),
("file.print", json!({ "format": "pdf" })),
] {
let mut p = params;
p["path"] = json!(src.to_string_lossy());
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("left things out"), "{cmd}: {e}");
assert_eq!(std::fs::read(&src).unwrap(), original, "{cmd}");
}
std::fs::remove_dir_all(d).ok();
}
+1
View File
@@ -22,6 +22,7 @@ vectorcraft-render = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
ruzstd = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
+333
View File
@@ -0,0 +1,333 @@
//! The scanner of the editing data: PostScript-like tokens, read line by line.
//!
//! - A line starting with `%_` holds tokens like any other line (they are marked [`Token::hidden`]:
//! readers of the printed format skip them as comments).
//! - Any other line starting with `%` is a [`Tok::Comment`] (the section markers).
//! - `%` later in a line starts a comment that is skipped.
//! - The binary image data after `%%BeginData:` is one [`Tok::Data`]; the binary previews of placed
//! files are skipped.
use super::super::lex::{find, hex_decode};
/// A token.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Tok<'a> {
Num(f64),
/// A literal `(…)` string, escapes undone.
Str(Vec<u8>),
/// A hexadecimal `<…>` string.
Hex(Vec<u8>),
/// A literal `/name`.
Name(&'a [u8]),
/// An operator (any other word, `[` and `]` included).
Word(&'a [u8]),
/// A comment line, without its first `%`.
Comment(&'a [u8]),
/// An image's samples: what follows `XI` in a `%%BeginData:` section.
Data(&'a [u8]),
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Token<'a> {
pub tok: Tok<'a>,
/// Read from a `%_` line.
pub hidden: bool,
}
/// Binary sections skipped whole: (begin, end) markers.
const SKIPPED: [(&[u8], &[u8]); 2] = [(b"%AI26_BeginPlacedObjectPreview", b"%AI26_EndPlacedObjectPreview"), (b"%%BeginDocument", b"%%EndDocument")];
fn is_space(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'\0')
}
fn is_delim(b: u8) -> bool {
matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
}
fn is_eol(b: u8) -> bool {
matches!(b, b'\r' | b'\n')
}
pub(super) struct Lexer<'a> {
src: &'a [u8],
pos: usize,
/// The current line started with `%_`.
hidden: bool,
}
impl<'a> Lexer<'a> {
pub fn new(src: &'a [u8]) -> Self {
Self { src, pos: 0, hidden: false }
}
fn peek(&self) -> Option<u8> {
self.src.get(self.pos).copied()
}
fn rest(&self) -> &'a [u8] {
self.src.get(self.pos..).unwrap_or_default()
}
fn at_line_start(&self) -> bool {
self.pos == 0 || self.src.get(self.pos - 1).is_some_and(|b| is_eol(*b))
}
/// The rest of the current line (without its end), the position moved past it.
fn line(&mut self) -> &'a [u8] {
let rest = self.rest();
let n = rest.iter().position(|b| is_eol(*b)).unwrap_or(rest.len());
self.pos += n;
rest.get(..n).unwrap_or_default()
}
/// Skip past the next `needle` (the end of data that isn't tokens) → what was skipped.
pub fn skip_past(&mut self, needle: &[u8]) -> &'a [u8] {
let rest = self.rest();
let n = find(rest, needle).map_or(rest.len(), |i| i + needle.len());
self.pos += n;
rest.get(..n).unwrap_or_default()
}
/// The next token; `None` at the end.
pub fn next_token(&mut self) -> Option<Token<'a>> {
loop {
let b = self.peek()?;
if is_eol(b) {
self.pos += 1;
self.hidden = false;
continue;
}
if is_space(b) {
self.pos += 1;
continue;
}
if b == b'%' {
if self.at_line_start() {
if self.rest().starts_with(b"%_") {
self.pos += 2;
self.hidden = true;
continue;
}
let line = self.line();
if let Some(t) = self.special(line) {
return Some(t);
}
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false });
}
// A comment later in a line.
self.line();
continue;
}
let hidden = self.hidden;
self.pos += 1;
let tok = match b {
b'(' => Tok::Str(self.string()),
b'<' => {
let rest = self.rest();
let end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len());
self.pos += (end + 1).min(rest.len());
// Malformed digits read as an empty string.
Tok::Hex(rest.get(..end).and_then(hex_decode).unwrap_or_default())
}
b'[' | b']' | b'{' | b'}' | b')' | b'>' => Tok::Word(self.src.get(self.pos - 1..self.pos).unwrap_or_default()),
b'/' => Tok::Name(self.word()),
_ => {
self.pos -= 1;
let w = self.word();
match number(w) {
Some(v) => Tok::Num(v),
None => Tok::Word(w),
}
}
};
return Some(Token { tok, hidden });
}
}
/// The token a comment line stands for when it starts a section read differently: image data
/// (`%%BeginData:` followed by `XI`) and skipped binary sections.
fn special(&mut self, line: &'a [u8]) -> Option<Token<'a>> {
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| line.starts_with(b)) {
let after = find(self.rest(), end).map_or(self.src.len(), |i| self.pos + i);
self.pos = after;
self.line();
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false });
}
let n = line.strip_prefix(b"%%BeginData:")?;
let n: usize = std::str::from_utf8(n).ok()?.split_whitespace().next()?.parse().ok()?;
// The count starts after the comment's line end character (a following `\n` counts).
let from = self.pos + 1;
let region = self.src.get(from..)?;
let skip = region.iter().take_while(|b| is_space(**b)).count();
if !region.get(skip..)?.starts_with(b"XI") {
return None;
}
let mut data = from + skip + 2;
match (self.src.get(data), self.src.get(data + 1)) {
(Some(b'\r'), Some(b'\n')) => data += 2,
(Some(b'\r' | b'\n'), _) => data += 1,
_ => {}
}
// The count leaves out an alpha channel: the data ends where `%%EndData` starts.
let declared = from.saturating_add(n).min(self.src.len()).max(data);
let window =
self.src.get(declared.saturating_sub(4).max(data)..declared.saturating_add(n.saturating_mul(2)).saturating_add(64).min(self.src.len()));
let end = match window.and_then(|w| find(w, b"%%EndData")) {
Some(i) => declared.saturating_sub(4).max(data) + i,
None => declared,
};
self.pos = end;
Some(Token { tok: Tok::Data(self.src.get(data..end).unwrap_or_default()), hidden: false })
}
/// A regular token's characters.
fn word(&mut self) -> &'a [u8] {
let start = self.pos;
while let Some(b) = self.peek() {
if is_space(b) || is_delim(b) {
break;
}
self.pos += 1;
}
self.src.get(start..self.pos).unwrap_or_default()
}
/// A literal string after its `(` (an unterminated one ends with the data).
fn string(&mut self) -> Vec<u8> {
let mut out = vec![];
let mut depth = 0usize;
while let Some(b) = self.peek() {
self.pos += 1;
match b {
b'(' => {
depth += 1;
out.push(b);
}
b')' if depth == 0 => break,
b')' => {
depth -= 1;
out.push(b);
}
b'\\' => {
let Some(e) = self.peek() else { break };
self.pos += 1;
match e {
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'b' => out.push(8),
b'f' => out.push(12),
b'\r' => {
if self.peek() == Some(b'\n') {
self.pos += 1;
}
}
b'\n' => {}
b'0'..=b'7' => {
let mut v = u32::from(e - b'0');
for _ in 0..2 {
match self.peek() {
Some(d @ b'0'..=b'7') => {
v = v * 8 + u32::from(d - b'0');
self.pos += 1;
}
_ => break,
}
}
out.push((v & 0xff) as u8);
}
other => out.push(other),
}
}
_ => out.push(b),
}
}
out
}
}
/// A finite number token (`12`, `-3.5`, `.5`, `1e-3`), or `None`.
fn number(w: &[u8]) -> Option<f64> {
let first = *w.first()?;
if !(first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.')) {
return None;
}
if w.iter().any(|b| b.is_ascii_alphabetic() && !matches!(b, b'e' | b'E')) {
return None;
}
let v: f64 = std::str::from_utf8(w).ok()?.parse().ok()?;
v.is_finite().then_some(v)
}
#[cfg(test)]
mod tests {
use super::*;
fn all(src: &[u8]) -> Vec<Token<'_>> {
let mut l = Lexer::new(src);
std::iter::from_fn(|| l.next_token()).collect()
}
fn t(tok: Tok<'_>, hidden: bool) -> Token<'_> {
Token { tok, hidden }
}
#[test]
fn hidden_lines_and_comments() {
let toks = all(b"%AI5_BeginLayer\r\n1 (a\\)b) Ln\r%_/X : ;\n0 %_BS\n<4142>\n");
assert_eq!(
toks,
vec![
t(Tok::Comment(b"AI5_BeginLayer"), false),
t(Tok::Num(1.0), false),
t(Tok::Str(b"a)b".to_vec()), false),
t(Tok::Word(b"Ln"), false),
t(Tok::Name(b"X"), true),
t(Tok::Word(b":"), true),
t(Tok::Word(b";"), true),
t(Tok::Num(0.0), false),
t(Tok::Hex(b"AB".to_vec()), false),
]
);
}
#[test]
fn image_data_is_one_token() {
// The count starts after the comment's `\r`: `\nXI\n` and 3 samples; an alpha channel
// after them isn't counted.
let src = b"[1 0 0 1 0 0] XI\r\n%%BeginData: 7\r\nXI\n(%)A%%EndData\r\nXH\r\n";
let toks = all(src);
assert!(toks.contains(&t(Tok::Data(b"(%)A"), false)), "{toks:?}");
assert_eq!(toks.last(), Some(&t(Tok::Word(b"XH"), false)));
// A section of hex lines is comments.
let toks = all(b"%%BeginData: 9 Hex Bytes\r\n%00FF\r\n%%EndData\r\n1\r\n");
assert_eq!(toks.last(), Some(&t(Tok::Num(1.0), false)));
}
#[test]
fn binary_previews_are_skipped() {
let toks = all(b"%AI26_BeginPlacedObjectPreview\r\n2 1 (\x00\xff\r\n%AI26_EndPlacedObjectPreview\r\nN\r\n");
assert_eq!(toks.last(), Some(&t(Tok::Word(b"N"), false)));
assert_eq!(toks.len(), 2);
}
#[test]
fn skip_past_data() {
let mut l = Lexer::new(b"/Binary : /ASCII85Decode ,\r\n%_(abc[%\r\n%x~>\r\n%_; 1");
for _ in 0..4 {
l.next_token();
}
assert_eq!(l.skip_past(b"~>"), b"\r\n%_(abc[%\r\n%x~>");
assert_eq!(l.next_token().map(|t| t.tok), Some(Tok::Word(b";")));
assert_eq!(l.next_token().map(|t| t.tok), Some(Tok::Num(1.0)));
}
#[test]
fn hostile_input_ends() {
for src in [&b"(unterminated \\"[..], b"<12", b"%%BeginData: 99999999999\r\nXI", b"%%BeginData: 5\rXI\r", b"/", b"1e999 nan"] {
let toks = all(src);
assert!(toks.len() < 10);
}
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Illustrator's editing data: the document as the app keeps it, which an Illustrator EPS carries
//! after its printed page and a `.ai` file (PDF compatible or not) in its first page's
//! `/PieceInfo /Illustrator /Private` streams.
//!
//! - In an EPS it starts at the `%AI9_PrivateDataBegin` comment: header comments, then
//! `%AI24_DataStream` (Zstandard) or `%AI9_DataStream` (zlib) and the compressed data as ASCII85
//! on comment lines, up to `%AI9_PrivateDataEnd`. Data that isn't compressed follows the header
//! as it is.
//! - In a `.ai` file the streams joined start with `%AI24_ZStandard_Data` (Zstandard) or
//! `%AI12_CompressedData` (zlib), else are the data itself ([`decode_private`]).
//!
//! The data is a PostScript-like program of the legacy Illustrator format's operators (layers,
//! groups, paths, colours, gradients, images) with newer ones (transparency, hidden objects) and
//! dictionaries (names, artboards, type). `lex` scans it, `read` builds the document.
//!
//! Reading is bounded: the data decompresses to at most [`MAX_DATA`], and the reader caps nesting,
//! objects and points. Anything it doesn't read is an error: the caller opens the file the way it
//! did before (the printed page, or the PDF), with a warning saying why.
mod lex;
mod obj;
mod paint;
mod read;
use std::io::Read as _;
use super::lex::find;
use crate::ps;
pub(crate) use read::slot_of;
pub use read::{Structure, read};
#[cfg(test)]
mod tests;
/// Largest editing data read, decompressed (bytes).
pub const MAX_DATA: u64 = 256 << 20;
/// Largest Zstandard window accepted (a frame asking for more is damaged data).
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
/// Most Zstandard frames read (the app writes one).
const MAX_FRAMES: usize = 4096;
const EPS_BEGIN: &[u8] = b"%AI9_PrivateDataBegin";
const EPS_END: &[u8] = b"%AI9_PrivateDataEnd";
/// The editing data an Illustrator EPS carries (`ps`: its PostScript), decoded: `None` without
/// one, an error when it can't be decoded.
pub fn eps_data(ps: &[u8]) -> Option<Result<Vec<u8>, String>> {
let start = find(ps, EPS_BEGIN)? + EPS_BEGIN.len();
let rest = ps.get(start..)?;
let section = rest.get(..find(rest, EPS_END).unwrap_or(rest.len())).unwrap_or_default();
Some(eps_section(section))
}
/// The editing data between the EPS markers: header comments, then the data (encoded or not).
fn eps_section(section: &[u8]) -> Result<Vec<u8>, String> {
let mut header = Vec::new();
let mut lines = section.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty());
let mut codec = None;
for line in lines.by_ref() {
if line.starts_with(b"%AI24_DataStream") {
codec = Some(Codec::Zstd);
break;
}
if line.starts_with(b"%AI9_DataStream") {
codec = Some(Codec::Zlib);
break;
}
header.extend_from_slice(line);
header.extend_from_slice(b"\n");
if header.len() as u64 > MAX_DATA {
return Err(too_large());
}
}
let Some(codec) = codec else {
// Not compressed: the section is the data, as it is (images' samples are binary).
if section.len() as u64 > MAX_DATA {
return Err(too_large());
}
return Ok(section.to_vec());
};
let mut text = String::new();
for line in lines {
let body = line.strip_prefix(b"%").unwrap_or(line);
text.push_str(std::str::from_utf8(body).map_err(|_| damaged("its ASCII85 encoding"))?);
if text.len() as u64 > MAX_DATA {
return Err(too_large());
}
}
if !text.contains("~>") {
text.push_str("~>");
}
let packed = ps::ascii85_decode(&text).ok_or_else(|| damaged("its ASCII85 encoding"))?;
let mut data = header;
data.extend(codec.decode(&packed, MAX_DATA.saturating_sub(data.len() as u64))?);
Ok(data)
}
/// The editing data of a `.ai` file: its `AIPrivateData` streams joined (`raw`), decompressed. The
/// compressed data may follow header comments that aren't.
pub fn decode_private(raw: &[u8]) -> Result<Vec<u8>, String> {
if raw.len() as u64 > MAX_DATA {
return Err(too_large());
}
for (marker, codec) in [(&b"%AI24_ZStandard_Data"[..], Codec::Zstd), (b"%AI12_CompressedData", Codec::Zlib)] {
if let Some(at) = find(raw, marker) {
let (header, body) = raw.split_at(at);
let mut data = header.to_vec();
data.extend(codec.decode(body.get(marker.len()..).unwrap_or_default(), MAX_DATA.saturating_sub(header.len() as u64))?);
return Ok(data);
}
}
Ok(raw.to_vec())
}
#[derive(Clone, Copy)]
enum Codec {
Zstd,
Zlib,
}
impl Codec {
/// `data` decompressed, at most `max` bytes.
fn decode(self, data: &[u8], max: u64) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
match self {
Codec::Zlib => {
flate2::read::ZlibDecoder::new(data).take(max + 1).read_to_end(&mut out).map_err(|_| damaged("its zlib compression"))?;
}
Codec::Zstd => {
// The data may be several frames one after the other, padded with zeros.
let end = data.iter().rposition(|b| *b != 0).map_or(0, |i| i + 1);
let mut src = data.get(..end).unwrap_or_default();
let mut frames = 0usize;
while !src.is_empty() {
frames += 1;
if frames > MAX_FRAMES {
return Err(damaged("its Zstandard compression"));
}
let left = (max + 1).saturating_sub(out.len() as u64);
if left == 0 {
break;
}
let d = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW)
.map_err(|_| damaged("its Zstandard compression"))?;
d.take(left).read_to_end(&mut out).map_err(|_| damaged("its Zstandard compression"))?;
}
}
}
if out.len() as u64 > max {
return Err(too_large());
}
Ok(out)
}
}
fn damaged(what: &str) -> String {
format!("{what} is damaged")
}
fn too_large() -> String {
format!("it decompresses to more than {} MB", MAX_DATA >> 20)
}
+208
View File
@@ -0,0 +1,208 @@
//! Operand values, and the dictionaries the editing data writes postfix: `/Type :` opens one,
//! `values key ,` adds an entry (a type name such as `/Real` may stand before the key; in an
//! `/Array` there is no key), `;` closes it (after a last `values /key` entry, or a bare value: its
//! content), leaving it as a value.
use std::rc::Rc;
#[derive(Clone, Debug, PartialEq)]
pub(super) enum V {
Num(f64),
Str(Vec<u8>),
Name(String),
Arr(Vec<V>),
Obj(Rc<Obj>),
/// `[`, until its `]`.
Mark,
}
impl V {
pub fn num(&self) -> Option<f64> {
match self {
V::Num(v) => Some(*v),
_ => None,
}
}
pub fn obj(&self) -> Option<&Obj> {
match self {
V::Obj(o) => Some(o),
_ => None,
}
}
/// A string's or name's text.
pub fn text(&self) -> Option<String> {
match self {
V::Str(s) => Some(text(s)),
V::Name(n) => Some(n.clone()),
_ => None,
}
}
}
/// Text from the data's bytes: UTF-8, else Windows-1252 (Latin-1 for its gaps).
pub(super) fn text(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => bytes.iter().map(|b| cp1252(*b)).collect(),
}
}
fn cp1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
'˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9f => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
_ => char::from(b),
}
}
/// A dictionary.
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct Obj {
pub ty: String,
/// Entries in order: the key (`None` in an array) and the values before it.
pub entries: Vec<(Option<String>, Vec<V>)>,
/// Values closed without a key.
pub content: Vec<V>,
}
impl Obj {
/// The values of entry `key` (the first one).
pub fn get(&self, key: &str) -> Option<&[V]> {
self.entries.iter().find(|(k, _)| k.as_deref() == Some(key)).map(|(_, v)| v.as_slice())
}
/// The numbers of entry `key`.
pub fn nums(&self, key: &str) -> Vec<f64> {
self.get(key).unwrap_or_default().iter().filter_map(V::num).collect()
}
/// The first text value of entry `key`.
pub fn text(&self, key: &str) -> Option<String> {
self.get(key)?.iter().find(|v| matches!(v, V::Str(_))).and_then(V::text)
}
/// The dictionary value of entry `key`.
pub fn obj(&self, key: &str) -> Option<&Obj> {
self.get(key)?.iter().find_map(V::obj)
}
/// The dictionaries an array holds, in order.
pub fn items(&self) -> impl Iterator<Item = &Obj> {
self.entries.iter().flat_map(|(_, v)| v.iter()).chain(self.content.iter()).filter_map(V::obj)
}
/// The first text of the content.
pub fn content_text(&self) -> Option<String> {
self.content.iter().find_map(V::text)
}
}
/// Add the values on `stack` above `base` to `o` as one entry (`,`): the last is the key, except
/// in an array.
pub(super) fn add_entry(o: &mut Obj, stack: &mut Vec<V>, base: usize) {
let mut vals: Vec<V> = stack.drain(base.min(stack.len())..).collect();
if o.ty == "Array" {
o.entries.push((None, vals));
return;
}
let key = match vals.last() {
Some(V::Str(_) | V::Name(_)) => vals.pop().and_then(|k| k.text()),
_ => None,
};
o.entries.push((key, vals));
}
/// Close `o` (`;`): a last entry ending with a name is an entry, other values are its content.
pub(super) fn close(o: &mut Obj, stack: &mut Vec<V>, base: usize) {
if stack.len() <= base {
return;
}
if o.ty != "Array" && matches!(stack.last(), Some(V::Name(_))) {
add_entry(o, stack, base);
} else {
let vals: Vec<V> = stack.drain(base.min(stack.len())..).collect();
o.content.extend(vals);
}
}
/// An object name from its XML id (`AI10_ArtUID`): `_` stands for a space and `_xHH_` for a
/// character; ids made unique end with `_` and 20 or more digits.
pub(super) fn xml_name(id: &str) -> String {
let id = strip_unique(id);
let mut out = String::with_capacity(id.len());
let mut rest = id;
while let Some(c) = rest.chars().next() {
if c == '_' {
if let Some((ch, n)) = escaped(rest) {
out.push(ch);
rest = rest.get(n..).unwrap_or_default();
continue;
}
out.push(' ');
} else {
out.push(c);
}
rest = rest.get(c.len_utf8()..).unwrap_or_default();
}
out
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
let end = body.find('_')?;
let hex = body.get(..end)?;
if hex.is_empty() || hex.len() > 6 {
return None;
}
let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?;
Some((c, end + 3))
}
/// `id` without the `_000…_` suffix that makes it unique.
fn strip_unique(id: &str) -> &str {
let Some(body) = id.strip_suffix('_') else { return id };
let Some(at) = body.rfind('_') else { return id };
let digits = body.get(at + 1..).unwrap_or_default();
if digits.len() >= 20 && digits.bytes().all(|b| b.is_ascii_digit()) { body.get(..at).unwrap_or(id) } else { id }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_from_xml_ids() {
assert_eq!(xml_name("Layer_1"), "Layer 1");
assert_eq!(xml_name("My_Rect__x23_2"), "My Rect #2");
assert_eq!(xml_name("_x31_abc__x28_paren_x29_"), "1abc (paren)");
assert_eq!(xml_name("Ünï_日本"), "Ünï 日本");
assert_eq!(xml_name("S_00000092429200007574234970000005974767392385616558_"), "S");
assert_eq!(xml_name("a_x_"), "a x ");
assert_eq!(xml_name("_xD800_"), " xD800 ");
}
#[test]
fn entries_and_content() {
let mut o = Obj { ty: "XMLUID".into(), ..Obj::default() };
let mut st = vec![V::Num(9.0), V::Str(b"Main".to_vec())];
close(&mut o, &mut st, 1);
assert_eq!(o.content_text().as_deref(), Some("Main"));
assert_eq!(st, vec![V::Num(9.0)]);
let mut d = Obj { ty: "Dictionary".into(), ..Obj::default() };
let mut st = vec![V::Num(1.0), V::Num(2.0), V::Name("RealPoint".into()), V::Str(b"P".to_vec())];
add_entry(&mut d, &mut st, 0);
let mut st = vec![V::Name("Def".into())];
close(&mut d, &mut st, 0);
assert_eq!(d.nums("P"), vec![1.0, 2.0]);
assert_eq!(d.get("Def"), Some(&[][..]));
let mut a = Obj { ty: "Array".into(), ..Obj::default() };
let mut st = vec![V::Obj(Rc::new(d.clone()))];
add_entry(&mut a, &mut st, 0);
assert_eq!(a.items().count(), 1);
}
#[test]
fn latin_text() {
assert_eq!(text(b"caf\xe9 \x80"), "café €");
assert_eq!(text("ü".as_bytes()), "ü");
}
}
+277
View File
@@ -0,0 +1,277 @@
//! Colours and gradients of the editing data.
//!
//! - Colours: `g`/`G` grey (1 is white), `k`/`K` CMYK, `x`/`X` a named CMYK colour at a tint
//! (`c m y k (name) tint`; tint 0 is the full colour), `Xa`/`XA` RGB (`c m y k r g b`, the CMYK
//! values its equivalent), `Xx`/`XX` a named colour of either model
//! (`c m y k [r g b] (name) tint type`). Lower case paints fills, upper case strokes.
//! - Gradient definitions: `(name) type stops Bd`, its stops as `colour style [opacity 6]
//! midpoint ramp Bs` (style 0 grey, 1 CMYK, 2 RGB with its CMYK, 3 and 4 named colours), `BD`.
//! - A gradient on an object: `[flag] Bb` (1: the stroke), `flag (name) x y angle length a b c d
//! tx ty Bg`, the gradient matrix `a b c d tx ty Bm`, the focal point `x y angle length Bh`,
//! `flag BB`.
use std::collections::HashMap;
use kurbo::{Affine, Point, Vec2};
use vectorcraft_color::gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
use vectorcraft_color::{Color, Paint};
use super::obj::V;
/// Largest number of stops read in one gradient.
const MAX_STOPS: usize = 256;
/// A colour read with the swatch it names (spot and global colours).
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Named {
pub name: String,
/// The full colour.
pub color: Color,
pub tint: f32,
}
/// The paint of a colour operator's operands (`op`: its name, lower case or not), with the named
/// colour it uses. `None` when the operands don't fit.
pub(super) fn color_op(op: &str, vals: &[V]) -> Option<(Paint, Option<Named>)> {
let nums: Vec<f32> = vals.iter().filter_map(V::num).map(|v| v as f32).collect();
let unit = |v: f32| v.clamp(0.0, 1.0);
let last = |n: usize| nums.get(nums.len().checked_sub(n)?..);
match op.to_ascii_lowercase().as_str() {
"g" => Some((Paint::solid(Color::gray(1.0 - unit(*nums.last()?))), None)),
"k" => {
let [c, m, y, k] = last(4)? else { return None };
Some((Paint::solid(Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))), None))
}
"xa" => {
let [r, g, b] = last(3)? else { return None };
Some((Paint::solid(Color::rgb(unit(*r), unit(*g), unit(*b))), None))
}
"x" => named(vals, Some(false)),
"xx" => named(vals, None),
_ => None,
}
}
/// `c m y k (name) tint x` or `c m y k [r g b] (name) tint type Xx` (type 1: RGB); `rgb` forces
/// the model.
fn named(vals: &[V], rgb: Option<bool>) -> Option<(Paint, Option<Named>)> {
let at = vals.iter().rposition(|v| matches!(v, V::Str(_)))?;
let name = vals.get(at)?.text()?;
let before: Vec<f32> = vals.get(..at)?.iter().filter_map(V::num).map(|v| v as f32).collect();
let after: Vec<f32> = vals.get(at + 1..)?.iter().filter_map(V::num).map(|v| v as f32).collect();
let unit = |v: f32| v.clamp(0.0, 1.0);
let tint = 1.0 - unit(*after.first()?);
let n = before.len();
let color = if rgb.unwrap_or(after.get(1).is_some_and(|t| *t == 1.0)) {
let [r, g, b] = before.get(n.checked_sub(3)?..)? else { return None };
Color::rgb(unit(*r), unit(*g), unit(*b))
} else {
// The CMYK values come first (an RGB equivalent may follow them).
let from = if n >= 7 { n - 7 } else { n.checked_sub(4)? };
let [c, m, y, k] = before.get(from..from + 4)? else { return None };
Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k))
};
let paint = Paint::Solid { color: color.tinted(tint), swatch: Some(name.clone()), tint };
Some((paint, Some(Named { name, color, tint })))
}
/// The colour of a gradient stop's colour values and style.
fn stop_color(style: f64, comps: &[V]) -> Option<(Color, Option<Named>)> {
let nums: Vec<f32> = comps.iter().filter_map(V::num).map(|v| v as f32).collect();
let unit = |v: f32| v.clamp(0.0, 1.0);
let tail = |n: usize| nums.get(nums.len().checked_sub(n)?..);
Some(match style as i64 {
0 => (Color::gray(1.0 - unit(*nums.last()?)), None),
1 => {
let [c, m, y, k] = tail(4)? else { return None };
(Color::cmyk(unit(*c), unit(*m), unit(*y), unit(*k)), None)
}
2 => {
let [r, g, b] = tail(3)? else { return None };
(Color::rgb(unit(*r), unit(*g), unit(*b)), None)
}
_ => {
let (paint, named) = named(comps, Some(style as i64 == 4))?;
(paint.color()?, named)
}
})
}
/// A gradient definition being read.
#[derive(Debug)]
pub(super) struct GradientDef {
pub name: String,
pub gradient: Gradient,
}
impl GradientDef {
/// `(name) type stops Bd`.
pub fn begin(vals: &[V]) -> Option<Self> {
let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None })?;
let nums: Vec<f64> = vals.iter().filter_map(V::num).collect();
let kind = if nums.first().copied() == Some(1.0) { GradientKind::Radial } else { GradientKind::Linear };
Some(Self { name, gradient: Gradient { kind, stops: vec![] } })
}
/// A stop (`Bs`).
pub fn stop(&mut self, vals: &[V], spots: &mut Vec<Named>) {
if self.gradient.stops.len() >= MAX_STOPS {
return;
}
let n = vals.len();
let num = |i: usize| vals.get(i).and_then(V::num);
let (Some(ramp), Some(mid)) = (n.checked_sub(1).and_then(num), n.checked_sub(2).and_then(num)) else { return };
// Newer stops have an opacity and the marker 6 before the midpoint.
let newer = n >= 5 && n.checked_sub(3).and_then(num) == Some(6.0);
let (style_at, opacity) = if newer { (n.checked_sub(5), n.checked_sub(4).and_then(num).unwrap_or(1.0)) } else { (n.checked_sub(3), 1.0) };
let (Some(style_at), Some(style)) = (style_at, style_at.and_then(num)) else { return };
let Some((color, named)) = stop_color(style, vals.get(..style_at).unwrap_or_default()) else { return };
let mut stop = GradientStop::new((ramp / 100.0).clamp(0.0, 1.0) as f32, color);
stop.opacity = opacity.clamp(0.0, 1.0) as f32;
stop.midpoint = (mid / 100.0).clamp(0.13, 0.87) as f32;
if let Some(n) = named {
stop.set_color(n.color.tinted(n.tint), Some((n.name.clone(), n.tint)));
spots.push(n);
}
self.gradient.stops.push(stop);
}
/// The finished gradient (`BD`): stops in order of their ramp points.
pub fn finish(mut self) -> Option<(String, Gradient)> {
if self.gradient.stops.is_empty() {
return None;
}
self.gradient.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
Some((self.name, self.gradient))
}
}
/// A gradient instance on an object, between `Bb` and `BB`.
#[derive(Clone, Debug, Default)]
pub(super) struct Instance {
pub stroke: bool,
pub name: Option<String>,
origin: Point,
angle: f64,
length: f64,
/// `Bg`'s own matrix.
bg: Affine,
/// The gradient matrix (`Bm`), when there is one.
bm: Option<Affine>,
/// The focal point's offset from the origin (`Bh`).
hilight: Vec2,
}
fn affine(n: &[f64]) -> Option<Affine> {
let [a, b, c, d, e, f] = n else { return None };
let m = Affine::new([*a, *b, *c, *d, *e, *f]);
(m.as_coeffs().iter().all(|v| v.is_finite())).then_some(m)
}
impl Instance {
pub fn begin(vals: &[V]) -> Self {
let stroke = vals.iter().filter_map(V::num).next_back() == Some(1.0);
Self { stroke, length: 1.0, ..Self::default() }
}
/// `flag (name) x y angle length a b c d tx ty Bg`.
pub fn bg(&mut self, vals: &[V]) {
let Some(at) = vals.iter().rposition(|v| matches!(v, V::Str(_))) else { return };
self.name = vals.get(at).and_then(V::text);
let n: Vec<f64> = vals.get(at + 1..).unwrap_or_default().iter().filter_map(V::num).collect();
if let [x, y, angle, length, m @ ..] = n.as_slice() {
self.origin = Point::new(*x, *y);
self.angle = *angle;
self.length = *length;
self.bg = affine(m).unwrap_or(Affine::IDENTITY);
}
}
/// `a b c d tx ty Bm`.
pub fn bm(&mut self, vals: &[V]) {
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
self.bm = n.get(n.len().saturating_sub(6)..).and_then(affine).filter(|m| m.determinant().abs() > 1e-12);
}
/// `x y angle length Bh`.
pub fn bh(&mut self, vals: &[V]) {
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
if let [x, y, ..] = n.as_slice() {
self.hilight = Vec2::new(*x, *y);
}
}
/// The paint, through `to_doc` (art space → the document), when the gradient is defined.
pub fn paint(&self, defs: &HashMap<String, Gradient>, to_doc: Affine) -> Option<Paint> {
let name = self.name.as_ref()?;
let gradient = defs.get(name)?.clone();
let m = to_doc * self.bm.unwrap_or(Affine::IDENTITY) * self.bg;
let a = self.angle.to_radians();
let dir = Vec2::new(a.cos(), a.sin()) * self.length;
let start = m * self.origin;
let end = m * (self.origin + dir);
if !(start.is_finite() && end.is_finite()) || start.distance(end) < 1e-9 {
return None;
}
// Across the vector, for an elliptical radial gradient.
let across = m * (self.origin + Vec2::new(-dir.y, dir.x));
let aspect = (across.distance(start) / start.distance(end)).clamp(0.01, 100.0);
let focal = (self.hilight.hypot() > 1e-9).then(|| m * (self.origin + self.hilight)).filter(|p| p.is_finite());
let radial = gradient.kind == GradientKind::Radial;
let geom = GradientGeom { start, end, aspect: if radial { aspect } else { 1.0 }, focal: if radial { focal } else { None } };
let angle = -(end - start).atan2().to_degrees();
Some(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle, swatch: Some(name.clone()), ..GradientPaint::new(gradient) })))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn nums(v: &[f64]) -> Vec<V> {
v.iter().map(|n| V::Num(*n)).collect()
}
#[test]
fn colours() {
let (p, _) = color_op("g", &nums(&[0.7])).unwrap();
assert!(matches!(p.color(), Some(Color::Gray { k }) if (k - 0.3).abs() < 1e-6));
let (p, _) = color_op("XA", &nums(&[0.0, 0.99, 1.0, 0.0, 1.0, 0.0, 0.0])).unwrap();
assert_eq!(p.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
let mut v = nums(&[0.0, 0.5, 1.0, 0.0]);
v.push(V::Str(b"MySpot".to_vec()));
v.push(V::Num(0.4));
let (p, named) = color_op("x", &v).unwrap();
let named = named.unwrap();
assert_eq!(named.name, "MySpot");
assert!((named.tint - 0.6).abs() < 1e-6);
assert!(matches!(p, Paint::Solid { swatch: Some(_), .. }));
assert!(color_op("k", &nums(&[1.0])).is_none());
}
#[test]
fn gradient_definition_and_instance() {
let mut d = GradientDef::begin(&[V::Str(b"G".to_vec()), V::Num(1.0), V::Num(2.0)]).unwrap();
let mut spots = vec![];
d.stop(&nums(&[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.5, 6.0, 30.0, 100.0]), &mut spots);
d.stop(&nums(&[0.81, 0.0, 1.0, 6.0, 50.0, 0.0]), &mut spots);
let (name, g) = d.finish().unwrap();
assert_eq!(name, "G");
assert_eq!(g.kind, GradientKind::Radial);
assert_eq!(g.stops.len(), 2);
assert_eq!(g.stops[1].opacity, 0.5);
assert!(matches!(g.stops[0].color, Color::Gray { k } if (k - 0.19).abs() < 1e-6));
let defs = HashMap::from([(name, g)]);
let mut i = Instance::begin(&[]);
let mut bg = vec![V::Num(1.0), V::Str(b"G".to_vec())];
bg.extend(nums(&[0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
i.bg(&bg);
i.bm(&nums(&[50.0, 0.0, 0.0, -50.0, 100.0, 100.0]));
let Some(Paint::Gradient(gp)) = i.paint(&defs, Affine::IDENTITY) else { panic!() };
let geom = gp.geom.unwrap();
assert_eq!(geom.start, Point::new(100.0, 100.0));
assert_eq!(geom.end, Point::new(150.0, 100.0));
assert!((geom.aspect - 1.0).abs() < 1e-9);
assert!(i.paint(&HashMap::new(), Affine::IDENTITY).is_none());
}
}
File diff suppressed because it is too large. Load diff
+428
View File
@@ -0,0 +1,428 @@
//! The editing data reader on synthetic data, written from the format as `read` documents it.
use std::sync::Arc;
use kurbo::{Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
use super::*;
/// Editing data: header comments (crop marks 0 0 200 100), then `body`.
fn stream(body: &str) -> String {
format!(
"%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"
)
}
/// One layer named `L` holding `art`.
fn layer(art: &str) -> String {
format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n0 A\n0 Xw\n{art}\nLB\n%AI5_EndLayer--\n")
}
fn read_ok(body: &str) -> Structure {
match read(stream(body).as_bytes()) {
Ok(i) => i,
Err(e) => panic!("{e}"),
}
}
/// The children of the first layer.
fn art(doc: &Document) -> Vec<Arc<Node>> {
doc.layers.first().and_then(|l| l.children()).cloned().unwrap_or_default()
}
fn fill(n: &Node) -> Paint {
n.appearance.fill().map(|f| f.paint.clone()).unwrap_or_default()
}
fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
format!("{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\n")
}
const RED: &str = "0 1 1 0 1 0 0 Xa\n";
#[test]
fn layers_and_sublayers() {
let body = format!(
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Bottom) Ln\n{RED}{}f\n\
%AI5_BeginLayer\n0 1 0 0 0 0 0 -1 10 200 30 0 50 0 Lb\n(Inner) Ln\n{}f\nLB\n%AI5_EndLayer--\nLB\n%AI5_EndLayer--\n\
%AI5_BeginLayer\n1 0 1 1 1 0 1 3 79 79 255 0 70 0 Lb\n(Top) Ln\n{}f\nLB\n%AI5_EndLayer--\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0)
);
let doc = read_ok(&body).doc;
let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Bottom", "Top"], "bottom first");
let bottom = &doc.layers[0];
assert!(bottom.visible && !bottom.locked);
let kids = bottom.children().unwrap();
assert_eq!(kids.len(), 2);
let inner = &kids[1];
assert_eq!(inner.name.as_deref(), Some("Inner"));
assert!(!inner.visible && inner.locked);
let NodeKind::Layer { color, printable, .. } = &inner.kind else { panic!("a sublayer") };
assert_eq!((*color, *printable), (LayerColor::Custom([10, 200, 30]), false));
let NodeKind::Layer { color, preview, dim_images, .. } = &doc.layers[1].kind else { panic!() };
assert_eq!((*color, *preview, *dim_images), (LayerColor::Preset(3), false, Some(70)));
}
#[test]
fn groups_names_hidden_and_locked() {
let art_ = format!(
"0 Ae\nu\n{RED}{}f\n%_/ArtDictionary :\n%_/XMLUID : (My_Rect__x23_2) ; (AI10_ArtUID) ,\n%_;\n%_\n\
1 A\n0 Ae\nu\n0 A\n1 Xw\n{}f\n0 Xw\nU\n%_/ArtDictionary :\n%_(Inner group) /String (AIArtName) ,\n%_;\n9 () XW\n\
0 A\nU\n%_/ArtDictionary :\n%_/XMLUID : (Outer) ; (AI10_ArtUID) ,\n%_;\n%_\n9 () XW\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
);
let doc = read_ok(&layer(&art_)).doc;
let top = art(&doc);
assert_eq!(top.len(), 1);
let outer = &top[0];
assert_eq!(outer.name.as_deref(), Some("Outer"));
let kids = outer.children().unwrap();
assert_eq!(kids[0].name.as_deref(), Some("My Rect #2"));
let inner = &kids[1];
assert_eq!(inner.name.as_deref(), Some("Inner group"));
assert!(inner.locked, "locked when it opened");
let hidden = &inner.children().unwrap()[0];
assert!(!hidden.visible);
}
#[test]
fn paths_colours_and_strokes() {
let art_ = format!(
"{RED}0 R\n0 0 1 0 0 0 1 XA\n1 J 2 j 3 w 4 M [6 3 ]1 d\n1 XR\n{}b\n\
0.1 0.2 0.3 0.4 k\n{}f\n0.7 g\n{}F\n0 0.5 1 0 (Ink) 0.4 x\n{}f\n\
10 50 m\n20 60 30 60 40 50 c\n50 40 60 40 v\n70 60 80 60 y\nS\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0),
rect_path(60.0, 0.0, 70.0, 10.0),
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
assert_eq!(a.len(), 5);
// y up from the crop marks' top: (0, 0)-(10, 10) is at y 90-100.
let NodeKind::Path { path, rule, .. } = &a[0].kind else { panic!() };
assert_eq!(path.bounds(), Some(Rect::new(0.0, 90.0, 10.0, 100.0)));
assert!(path.subpaths[0].closed);
assert_eq!(path.subpaths[0].anchors.len(), 4, "the closing point merges with the first");
assert_eq!(*rule, vectorcraft_geom::FillRule::EvenOdd);
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
let st = a[0].appearance.stroke().unwrap();
assert_eq!((st.width, st.cap, st.join, st.miter_limit), (3.0, LineCap::Round, LineJoin::Bevel, 4.0));
assert_eq!(st.paint.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
let dash = st.dash.clone().unwrap();
assert_eq!((dash.pattern, dash.offset), (vec![6.0, 3.0], 1.0));
assert_eq!(fill(&a[1]).color(), Some(Color::cmyk(0.1, 0.2, 0.3, 0.4)));
assert!(matches!(fill(&a[2]).color(), Some(Color::Gray { k }) if (k - 0.3).abs() < 1e-6));
assert!(!matches!(&a[2].kind, NodeKind::Path { path, .. } if path.subpaths[0].closed), "F leaves the path open");
let Paint::Solid { swatch, tint, .. } = fill(&a[3]) else { panic!() };
assert_eq!(swatch.as_deref(), Some("Ink"));
assert!((tint - 0.6).abs() < 1e-6);
let ink = doc.swatch("Ink").unwrap();
assert!(ink.spot && ink.global);
// Curves: `c`, `v` (first control at the current point) and `y` (second at the end).
let NodeKind::Path { path, .. } = &a[4].kind else { panic!() };
assert_eq!(path.subpaths[0].anchors.len(), 4);
assert!(a[4].appearance.fill().is_none() && a[4].appearance.stroke().is_some());
}
#[test]
fn compound_paths_and_clipping() {
let art_ = format!(
"0 Ae\n*u\n{RED}{}f\n{}f\n*U\n%_/ArtDictionary :\n%_/XMLUID : (Donut) ; (AI10_ArtUID) ,\n%_;\n\
0 Ae\nq\n{}f\n{}h\nW\nn\nQ\n%_/ArtDictionary :\n%_/XMLUID : (Clipped) ; (AI10_ArtUID) ,\n%_;\n9 () XW\n",
rect_path(0.0, 0.0, 30.0, 30.0),
rect_path(10.0, 10.0, 20.0, 20.0),
rect_path(40.0, 0.0, 80.0, 40.0),
rect_path(50.0, 10.0, 60.0, 20.0),
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
let NodeKind::Compound { children, .. } = &a[0].kind else { panic!("{:?}", a[0].kind) };
assert_eq!((children.len(), a[0].name.as_deref()), (2, Some("Donut")));
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
let NodeKind::Group { children, clip: true } = &a[1].kind else { panic!("a clipping group") };
assert_eq!(a[1].name.as_deref(), Some("Clipped"));
// The clipping path goes first.
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
assert!(matches!(children[1].kind, NodeKind::Path { clipping: false, .. }));
// A clipping path in a layer clips the layer.
let doc = read_ok(&layer(&format!("{}f\n{}W\nn\n", rect_path(0.0, 0.0, 9.0, 9.0), rect_path(0.0, 0.0, 5.0, 5.0)))).doc;
let NodeKind::Layer { clip, children, .. } = &doc.layers[0].kind else { panic!() };
assert!(*clip && matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
}
#[test]
fn transparency_of_objects_and_groups() {
let art_ = format!(
"{RED}1 0.5 0 0 0 Xy\n{}f\n0 Ae\nu\n0 1 0 0 0 Xy\n{}f\nU\n2 0.7 1 1 0 Xy\n0 0 Xd\n6 () XW\n0 1 0 0 0 Xy\n{}f\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0),
);
let doc = read_ok(&layer(&art_)).doc;
let a = art(&doc);
assert_eq!((a[0].opacity, a[0].blend), (0.5, BlendMode::Multiply));
assert_eq!((a[1].opacity, a[1].blend, a[1].isolate, a[1].knockout), (0.7, BlendMode::Screen, true, Knockout::On));
let inner = &a[1].children().unwrap()[0];
assert_eq!(inner.opacity, 1.0);
assert_eq!((a[2].opacity, a[2].blend), (1.0, BlendMode::Normal), "the next object's state isn't the group's");
}
#[test]
fn gradients() {
let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\
%_0 0 0 0 1 0 0 2 1 6 40 0 Bs\n%_0 0 0 0 0 0 1 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let art_ = format!(
"{RED}{}Bb\n0 0 0 0 Bh\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n50 0 0 -50 100 50 Bm\nf\n0 BB\n{}f\n",
rect_path(50.0, 0.0, 150.0, 100.0),
rect_path(0.0, 0.0, 10.0, 10.0),
);
let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc;
let a = art(&doc);
let Paint::Gradient(g) = fill(&a[0]) else { panic!("{:?}", fill(&a[0])) };
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert_eq!(g.gradient.stops.len(), 2);
assert!((g.gradient.stops[0].midpoint - 0.4).abs() < 1e-6);
assert_eq!(g.gradient.stops[1].opacity, 0.5);
let geom = g.geom.unwrap();
assert_eq!((geom.start, geom.end), (Point::new(100.0, 50.0), Point::new(150.0, 50.0)));
// The gradient was the one object's.
assert_eq!(fill(&a[1]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
#[test]
fn artboards() {
let data = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_(One) /UnicodeString (Name) ,\n%_; ,\n\
%_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let art_ = rect_path(420.0, -70.0, 470.0, -20.0) + "f\n";
let doc = read_ok(&format!("{data}{}", layer(&art_))).doc;
let boards: Vec<_> = doc.artboards.iter().map(|a| (a.name.clone(), a.rect)).collect();
assert_eq!(boards, [("One".to_string(), Rect::new(0.0, 0.0, 300.0, 200.0)), ("Artboard 2".to_string(), Rect::new(400.0, 200.0, 600.0, 350.0))]);
let NodeKind::Path { path, .. } = &art(&doc)[0].kind else { panic!() };
assert_eq!(path.bounds(), Some(Rect::new(420.0, 220.0, 470.0, 270.0)));
// Without artboards or crop marks: the art size around the template box's centre.
let old = format!(
"%!PS-Adobe-3.0\n%AI5_ArtSize: 600 400\n%AI3_TemplateBox: 300.5 199.5 300.5 199.5\n%%EndComments\n{}",
layer("10 10 m\n20 20 L\nS\n")
);
let doc = read(old.as_bytes()).unwrap().doc;
assert_eq!(doc.artboards[0].rect, Rect::new(0.0, 0.0, 600.0, 400.0));
}
#[test]
fn a_drawn_look_stands_for_its_object() {
// The look (a group, with a part on `%_` lines for newer readers), then the object on `%_`
// lines and its style.
let hidden_rect = rect_path(0.0, 0.0, 10.0, 10.0).replace('\n', "\n%_");
let art_ = format!(
"0 Ae\nu\n%_0 Ae\n%_u\n%_{hidden_rect}S\n%_U\n{RED}{}f\n0 0 0 0 0 0 1 XA\n{}S\nU\n\
%_/ArtDictionary :\n%_/XMLUID : (Neon_00000092429200007574234970000005974767392385616558_) ; (AI10_ArtUID) ,\n%_;\n\
0 0.4 0 0 0 Xy\n0 0 Xd\n6 () XW\n%_{hidden_rect}n\n%_/ArtDictionary :\n%_/XMLUID : (Neon) ; (AI10_ArtUID) ,\n%_;\n1 (Neon style) XW\n\
0 1 0 0 0 Xy\n{}f\n",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(30.0, 0.0, 40.0, 10.0),
);
let r = read_ok(&layer(&art_));
let a = art(&r.doc);
assert_eq!(a.len(), 2, "{a:#?}");
assert_eq!(a[0].name.as_deref(), Some("Neon"));
assert_eq!(a[0].opacity, 0.4);
assert_eq!(a[0].children().unwrap().len(), 2, "the newer readers' part is left out");
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
}
/// Editing data with one layer holding an image: `matrix` (`[a b c d tx ty]`), its size, colour
/// space, kind and alpha channels, its samples, and the size the data comment declares.
fn image(matrix: &str, (w, h): (u32, u32), (space, kind, alpha): (&str, u32, u32), data: &[u8], declared: usize) -> Vec<u8> {
let mut s = format!(
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/{space} XN\n{matrix} {w} {h} 0 Xh\n\
{matrix} 0 0 {w} {h} {w} {h} 8 {kind} {alpha} 0 1 0 4 4 0 0\n%%BeginData: {declared}\rXI\n"
)
.into_bytes();
s.extend_from_slice(data);
s.extend_from_slice(b"%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\nLB\r\n%AI5_EndLayer--\r\n");
let mut out = stream("").into_bytes();
let at = out.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap();
out.splice(at..at, s);
out
}
fn pixels(doc: &Document) -> (image::RgbaImage, kurbo::Affine) {
let a = art(doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
let blob = doc.images.get(&im.key).unwrap();
(image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), im.xf)
}
#[test]
fn images_with_alpha_channels() {
// RGB samples, then the alpha channel (not counted in the data's size).
let data = [255, 0, 0, 0, 255, 0, 255, 128];
let doc = read(&image("[ 10 0 0 10 50 90 ]", (2, 1), ("DeviceRGB", 3, 1), &data, 10)).unwrap().doc;
let (img, xf) = pixels(&doc);
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 255, 0, 128]));
// Pixel space (y down) to the top left at (50, 90) in art space: (50, 10) on the page.
assert_eq!(xf * Point::ZERO, Point::new(50.0, 10.0));
assert_eq!(xf * Point::new(2.0, 1.0), Point::new(70.0, 20.0));
// CMYK and grey.
let doc = read(&image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceCMYK", 4, 0), &[0, 0, 0, 255], 6)).unwrap().doc;
let px = pixels(&doc).0.get_pixel(0, 0).0;
assert!(px[0] < 64 && px[3] == 255, "100% black is dark: {px:?}");
let doc = read(&image("[ 1 0 0 1 0 100 ]", (2, 1), ("DeviceGray", 1, 0), &[0, 200], 6)).unwrap().doc;
assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [200, 200, 200, 255]);
// Data cut short: the image goes, with a warning.
let r = read(&image("[ 1 0 0 1 0 100 ]", (4, 4), ("DeviceRGB", 3, 0), &[1, 2, 3], 6)).unwrap();
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
}
#[test]
fn text_objects_are_slots_named_after_their_story() {
let words = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n3 /StoryIndex ,\n;\n%_/ArtDictionary :\n%_/XMLUID : (Title) ; (AI10_ArtUID) ,\n%_;\n";
let r = read_ok(&layer(&format!("1 Xw\n{words}0 Xw\n")));
let a = art(&r.doc);
assert_eq!(slot_of(a[0].name.as_deref()), Some(Some(3)));
assert!(!a[0].visible);
assert_eq!(r.slot_names.get(&a[0].id).map(String::as_str), Some("Title"));
// A copy on `%_` lines of a story that has a plain text object goes; one without stays.
let commented = |story: u32| -> String { format!("/AI11Text :\n{story} /StoryIndex ,\n;\n").lines().map(|l| format!("%_{l}\n")).collect() };
let r = read_ok(&layer(&format!("/AI11Text :\n0 /StoryIndex ,\n;\n{}{}", commented(0), commented(1))));
let stories: Vec<_> = art(&r.doc).iter().map(|n| slot_of(n.name.as_deref())).collect();
assert_eq!(stories, [Some(Some(0)), Some(Some(1))]);
}
#[test]
fn what_it_doesnt_read_is_an_error_where_it_shows() {
let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err();
assert!(e.contains("`frobnicate`"), "{e}");
let e = read(stream(&layer("(Jive) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n")).as_bytes()).unwrap_err();
assert!(e.contains("pattern fills"), "{e}");
let e = read(stream(&layer("/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")).as_bytes()).unwrap_err();
assert!(e.contains("symbols"), "{e}");
// On a layer that doesn't show, it is left out of it.
let hidden = "%AI5_BeginLayer\n0 1 1 1 0 0 0 1 255 79 79 0 50 0 Lb\n(Off) Ln\n1 2 frobnicate\n(J) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\nLB\n%AI5_EndLayer--\n";
let r = read_ok(hidden);
assert_eq!(r.hidden_unread.iter().cloned().collect::<Vec<_>>(), ["`frobnicate`", "pattern fills"]);
// Unbalanced containers, a layer that doesn't end, no layers.
assert!(read(stream(&layer("U\n")).as_bytes()).is_err());
assert!(read(stream("%AI5_BeginLayer\n(L) Ln\n0 Ae\nu\n").as_bytes()).is_err());
assert!(read(stream("").as_bytes()).is_err());
// The setup's procedures and what follows the page don't count.
read_ok(&format!("Adobe_level2_AI5 /initialize get exec\n{}", layer("")));
}
#[test]
fn sections_it_skips() {
let body = "%AI5_Begin_NonPrinting\n%AI14_BeginSymbol\n0 Ae\nu\nfrobnicate\n%AI10_EndSymbol\n%AI3_BeginPattern: (P)\nU\nU\n%AI3_EndPattern\n%AI5_End_NonPrinting--\n\
%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n%AI17_Begin_Content_if_version_gt:23 1\nfrobnicate\n%AI17_End_Versioned_Content\nfrobnicate\n%AI17_End_Versioned_Content\n\
%_/Document :\n%_/Binary : /ASCII85Decode ,\n%_(]<[%\n%;;;~>\n%_; (Profile) ,\n%_;\n";
let doc = read_ok(&format!("{body}{}", layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")))).doc;
assert_eq!(art(&doc).len(), 1);
}
#[test]
fn cmyk_documents() {
let doc = read_ok(&format!("%AI9_ColorModel: 2\n{}", layer(""))).doc;
assert_eq!(doc.color_mode, ColorMode::Cmyk);
}
#[test]
fn hostile_data_ends_without_panics() {
let deep = "0 Ae\nu\n".repeat(300) + &"U\n".repeat(300);
assert!(read(stream(&layer(&deep)).as_bytes()).unwrap_err().contains("nested too deeply"));
let keys = "/X :\n".repeat(300);
assert!(read(stream(&layer(&keys)).as_bytes()).is_err());
for junk in [
&b"%AI5_BeginLayer\n(\xff\xfe"[..],
b"%AI5_BeginLayer\n<zz",
b"]]]] [ [ ; , : : ;",
b"1e308 1e308 m 1e308 -1e308 L f",
b"%%BeginData: 5\rXI\n",
] {
let _ = read(junk);
}
// Images with absurd sizes are left out.
let r = read(&image("[ 1 0 0 1 0 0 ]", (1 << 20, 1 << 20), ("DeviceRGB", 3, 0), &[0; 12], 12)).unwrap();
assert!(art(&r.doc).is_empty());
}
fn zstd(data: &[u8]) -> Vec<u8> {
ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest)
}
/// An EPS whose page draws a line, and the editing data `packed` after it (`marker`: its codec).
fn eps(marker: &str, packed: &[u8]) -> Vec<u8> {
let lines: String = crate::ps::ascii85(packed).lines().map(|l| format!("%{l}\n")).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n%%EndComments\n0 0 moveto 10 10 lineto stroke\nshowpage\n%%EOF\n\
%AI9_PrivateDataBegin\n%!PS-Adobe-3.0 EPSF-3.0\n%AI3_Cropmarks: 0 0 200 100\n%AI5_FileFormat 14.0\n{marker}\n{lines}%AI9_PrivateDataEnd\n"
)
.into_bytes()
}
#[test]
fn eps_containers() {
let body = stream(&layer(&(rect_path(0.0, 0.0, 10.0, 10.0) + "f\n")));
let body = body.as_bytes();
for file in [eps("%AI24_DataStream", &zstd(body)), eps("%AI9_DataStream", &crate::ps::deflate(body))] {
let (ps, _) = crate::sections(&file).unwrap();
let data = eps_data(ps).unwrap().unwrap();
assert!(data.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0\n%AI3_Cropmarks"), "the header comes first");
assert!(data.ends_with(body));
assert_eq!(read(&data).unwrap().doc.layers.len(), 1);
}
// Damaged data says why.
let (ps, _) = crate::sections(&eps("%AI24_DataStream", b"not zstd")).map(|(p, t)| (p.to_vec(), t.map(<[u8]>::to_vec))).unwrap();
assert!(eps_data(&ps).unwrap().unwrap_err().contains("Zstandard"));
// Data that isn't compressed is used as it is.
let plain = format!("%!PS-Adobe-3.0\n%%EOF\n%AI9_PrivateDataBegin\n{}%AI9_PrivateDataEnd\n", stream(&layer("")));
assert!(read(&eps_data(plain.as_bytes()).unwrap().unwrap()).is_ok());
// An EPS without editing data has none.
let other = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n%%EndComments\n0 0 moveto 10 10 lineto stroke\nshowpage\n";
assert!(eps_data(other).is_none());
}
#[test]
fn ai_private_data() {
let body = stream(&layer(""));
let body = body.as_bytes();
let header = b"%%BoundingBox: 0 0 1 1\r\n";
for raw in [
[&b"%AI24_ZStandard_Data"[..], &zstd(body)].concat(),
[&header[..], b"%AI12_CompressedData", &crate::ps::deflate(body)].concat(),
body.to_vec(),
] {
let data = decode_private(&raw).unwrap();
assert!(data.ends_with(b"%%EOF\n"));
assert!(read(&data).is_ok());
}
assert!(decode_private(b"%AI12_CompressedDataxx").is_err());
}
#[test]
fn many_zstandard_frames_are_read_in_one_pass_and_capped() {
let frame = zstd(b" ");
let frames = |n: usize| -> Vec<u8> { [&b"%AI24_ZStandard_Data"[..], &frame.repeat(n), &[0; 64]].concat() };
let started = std::time::Instant::now();
assert_eq!(decode_private(&frames(4000)).unwrap().len(), 4000);
assert!(decode_private(&frames(50_000)).unwrap_err().contains("Zstandard"));
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
}
#[test]
fn closing_brackets_without_their_opening_dont_rescan_the_stack() {
let n = 200_000;
let body = format!("{}\n{}\n", "1 ".repeat(n), "] ".repeat(n));
let started = std::time::Instant::now();
let _ = read(stream(&layer(&body)).as_bytes());
assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed());
}
+552
View File
@@ -0,0 +1,552 @@
//! The text of an Illustrator editing copy.
//!
//! The editing copy of a file keeps the characters of its text objects, with their fonts, sizes,
//! colours and where they sit, in one text document of its own: between `%AI11_BeginTextDocument`
//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object
//! in a layer is only a stub naming its story in it (`/StoryIndex`).
//!
//! The format isn't documented. What is read here was worked out from the files of the project's
//! users, used locally and never committed, by comparing it with the type their pages draw: the
//! meaning of each key below was checked against the page's own type in more than a thousand text
//! objects, and it is only used for the keys that matched. A story the reader doesn't recognise (area
//! type, type on a path, anything with a frame that has more than a point and a matrix) is left
//! alone: it has no text, and the importer says so.
//!
//! Where type sits: a story lays its lines out round a point `F` (the anchor: the start of a line of
//! left-aligned type, the middle of a centred one, the end of a right-aligned one), each line with an
//! offset from it. The frame of the story gives a matrix that carries that layout onto the canvas of
//! the app, which is 16383 points a side; the canvas's centre, 8191.5, is the centre of the file's
//! `%AI3_TemplateBox`. See [`Story::place`].
use std::collections::BTreeMap;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextObject, TextRun};
use vectorcraft_geom::{Affine, Point};
/// Longest story read (bytes).
const MAX_STORY: usize = 1 << 20;
/// Biggest text document read (bytes, decoded).
const MAX_BYTES: usize = 64 << 20;
/// Deepest nesting read.
const MAX_DEPTH: usize = 64;
/// Most values read.
const MAX_VALUES: usize = 4_000_000;
/// The centre of the app's canvas, in the units of its text document.
const CANVAS_CENTRE: f64 = 8191.5;
/// A value of the text document.
#[derive(Debug, Clone, PartialEq)]
pub(super) enum Val {
Num(f64),
Str(String),
Name(String),
Bool(bool),
List(Vec<Val>),
Dict(BTreeMap<String, Val>),
}
impl Val {
fn get(&self, key: &str) -> Option<&Val> {
match self {
Val::Dict(d) => d.get(key),
_ => None,
}
}
fn at(&self, i: usize) -> Option<&Val> {
match self {
Val::List(l) => l.get(i),
_ => None,
}
}
fn num(&self) -> Option<f64> {
match self {
Val::Num(n) if n.is_finite() => Some(*n),
_ => None,
}
}
fn str(&self) -> Option<&str> {
match self {
Val::Str(s) => Some(s),
_ => None,
}
}
fn list(&self) -> &[Val] {
match self {
Val::List(l) => l,
_ => &[],
}
}
fn nums(&self) -> Option<Vec<f64>> {
self.list().iter().map(Val::num).collect()
}
/// The `name` entry of a dictionary that is a typed node (`/99 /name`).
fn is_node(&self, name: &str) -> bool {
matches!(self.get("99"), Some(Val::Name(n)) if n == name)
}
}
struct Reader<'a> {
b: &'a [u8],
at: usize,
budget: usize,
}
impl Reader<'_> {
fn skip_space(&mut self) {
while self.b.get(self.at).is_some_and(u8::is_ascii_whitespace) {
self.at += 1;
}
}
fn value(&mut self, depth: usize) -> Option<Val> {
self.skip_space();
self.budget = self.budget.checked_sub(1)?;
let c = *self.b.get(self.at)?;
match c {
b'<' if self.b.get(self.at + 1) == Some(&b'<') => {
if depth > MAX_DEPTH {
return None;
}
self.at += 2;
let mut d = BTreeMap::new();
loop {
self.skip_space();
if self.b.get(self.at..self.at + 2) == Some(b">>") {
self.at += 2;
return Some(Val::Dict(d));
}
let key = self.key()?;
let v = self.value(depth + 1)?;
d.insert(key, v);
}
}
b'[' => {
if depth > MAX_DEPTH {
return None;
}
self.at += 1;
let mut l = vec![];
loop {
self.skip_space();
if self.b.get(self.at) == Some(&b']') {
self.at += 1;
return Some(Val::List(l));
}
l.push(self.value(depth + 1)?);
}
}
b'(' => self.string(),
b'/' => Some(Val::Name(self.key()?)),
_ => {
let start = self.at;
while self.b.get(self.at).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'/')) {
self.at += 1;
}
let word = std::str::from_utf8(self.b.get(start..self.at)?).ok()?;
match word {
"true" => Some(Val::Bool(true)),
"false" => Some(Val::Bool(false)),
"" => None,
w => Some(Val::Num(w.parse().ok()?)),
}
}
}
}
/// A `/name`, without its slash.
fn key(&mut self) -> Option<String> {
self.skip_space();
if self.b.get(self.at) != Some(&b'/') {
return None;
}
self.at += 1;
let start = self.at;
while self.b.get(self.at).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'/')) {
self.at += 1;
}
Some(String::from_utf8_lossy(self.b.get(start..self.at)?).into_owned())
}
/// A string: up to the first `)` that isn't escaped; UTF-16 when it starts with a byte order mark.
fn string(&mut self) -> Option<Val> {
self.at += 1;
let mut bytes = vec![];
loop {
let c = *self.b.get(self.at)?;
self.at += 1;
match c {
b')' => break,
b'\\' => {
let e = *self.b.get(self.at)?;
self.at += 1;
bytes.push(match e {
b'n' => b'\n',
b'r' => b'\r',
b't' => b'\t',
b'b' => 8,
b'f' => 12,
b'0'..=b'7' => {
let mut v = u32::from(e - b'0');
for _ in 0..2 {
match self.b.get(self.at) {
Some(d @ b'0'..=b'7') => {
v = v * 8 + u32::from(d - b'0');
self.at += 1;
}
_ => break,
}
}
(v & 0xff) as u8
}
other => other,
});
}
c => bytes.push(c),
}
if bytes.len() > MAX_BYTES {
return None;
}
}
Some(Val::Str(match bytes.strip_prefix(&[0xfe, 0xff]) {
Some(rest) => {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect();
String::from_utf16_lossy(&units)
}
None => bytes.iter().map(|b| char::from(*b)).collect(),
}))
}
}
/// The values of a text document: its top-level `/key value` pairs.
fn read(bytes: &[u8]) -> Option<Val> {
let mut r = Reader { b: bytes, at: 0, budget: MAX_VALUES };
let mut d = BTreeMap::new();
loop {
r.skip_space();
if r.at >= bytes.len() {
return Some(Val::Dict(d));
}
let key = r.key()?;
let v = r.value(0)?;
d.insert(key, v);
}
}
/// How a paragraph's lines sit against its anchor.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Align {
Left,
Center,
Right,
}
/// The text of one story, in the units of the text document.
#[derive(Debug)]
pub(super) struct Story {
/// The characters, with `\r` ending paragraphs and `\x03` ending lines.
text: String,
/// The anchor of the layout.
anchor: (f64, f64),
/// Where each line starts, from the anchor.
lines: Vec<(f64, f64)>,
/// Carries the layout onto the canvas.
matrix: [f64; 6],
align: Align,
/// `(characters, style)` in order.
runs: Vec<(usize, Style)>,
}
#[derive(Clone, Debug)]
struct Style {
font: Option<String>,
size: f64,
fill: Option<[f64; 4]>,
stroke: Option<[f64; 4]>,
stroke_width: f64,
}
/// The text document of an editing copy.
pub(super) struct Texts {
stories: Vec<Val>,
fonts: Vec<String>,
/// The character style a story starts from.
defaults: Val,
frames: Vec<Val>,
}
impl Texts {
/// The text document in the editing copy `data`, if it has one that can be read.
pub(super) fn read(data: &[u8]) -> Option<Texts> {
fn find(h: &[u8], n: &[u8], from: usize) -> Option<usize> {
h.get(from..)?.windows(n.len()).position(|w| w == n).map(|p| p + from)
}
let start = find(data, b"%AI11_BeginTextDocument", 0)?;
let from = find(data, b"/ASCII85Decode", start)? + b"/ASCII85Decode".len();
let end = find(data, b"~>", from)? + 2;
// Each line after the first starts with a `%`.
// ASCII85 makes at most four bytes of a character (`z`): more than this would be over `MAX_BYTES`.
if end - from > MAX_BYTES / 4 {
return None;
}
let packed: String = String::from_utf8_lossy(data.get(from..end)?)
.split(['\r', '\n'])
.enumerate()
.map(|(i, l)| if i > 0 { l.strip_prefix('%').unwrap_or(l) } else { l })
.collect::<String>()
.trim_start_matches([' ', ','])
.to_string();
let bytes = crate::ps::ascii85_decode(&packed)?;
if bytes.len() > MAX_BYTES {
return None;
}
let doc = read(&bytes)?;
let fonts = doc
.get("0")?
.get("1")?
.get("0")?
.list()
.iter()
.map(|f| f.get("0").and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::str).unwrap_or_default().to_string())
.collect();
let main = doc.get("1")?;
Some(Texts {
stories: main.get("1")?.list().to_vec(),
fonts,
defaults: main.get("2")?.clone(),
frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(),
})
}
/// Story `i`, if it is one of point type this reads.
pub(super) fn story(&self, i: usize) -> Option<Story> {
let s = self.stories.get(i)?;
let text = s.get("0")?.get("0")?.str().filter(|t| t.len() <= MAX_STORY)?.to_string();
// The frame the story is in: a matrix and nothing else but its place.
let frame = self.frames.get(s.get("1")?.get("0")?.at(0)?.get("0")?.num()? as usize)?.get("0")?;
let carries = frame.get("2")?;
if carries.get("2").is_none() || carries.as_dict().is_some_and(|d| d.keys().any(|k| k != "2")) {
return None;
}
let matrix: [f64; 6] = carries.get("2")?.nums()?.try_into().ok()?;
let f = find_node(s.get("1")?.get("2")?, "F")?;
let anchor = f.get("0")?.get("0")?.nums()?;
let anchor = (*anchor.first()?, *anchor.get(1)?);
let mut lines = vec![];
let mut found = vec![];
find_nodes(f, "L", &mut found);
for l in found {
let off = l.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]);
let seg = l.get("6")?.list().iter().find(|c| c.is_node("S"))?;
let so = seg.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]);
lines.push((off.first()? + so.first()?, *off.get(1)?));
}
if lines.is_empty() {
return None;
}
let align = match s
.get("0")
.and_then(|t| t.get("5"))
.and_then(|p| p.get("0"))
.and_then(|p| p.at(0))
.and_then(|p| p.get("0"))
.and_then(|p| p.get("0"))
.and_then(|p| p.get("5"))
.and_then(|p| p.get("0"))
.and_then(Val::num)
{
Some(1.0) => Align::Right,
Some(2.0) => Align::Center,
_ => Align::Left,
};
let mut runs = vec![];
for run in s.get("0")?.get("6")?.get("0")?.list() {
let n = run.get("1")?.num()? as usize;
let style = run.get("0")?.get("0")?.get("6")?;
runs.push((n, self.style(style)));
}
Some(Story { text, anchor, lines, matrix, align, runs })
}
/// A character style: its own keys, else the document's.
fn style(&self, s: &Val) -> Style {
let pick = |k: &str| s.get(k).or_else(|| self.defaults.get(k));
let paint = |k: &str| -> Option<[f64; 4]> {
let p = pick(k)?.get("0")?;
// Kind 2 is CMYK: an opacity, then the four inks.
(p.get("0")?.num()? == 2.0).then(|| p.get("1")?.nums()).flatten().and_then(|v| Some([*v.get(1)?, *v.get(2)?, *v.get(3)?, *v.get(4)?]))
};
let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false)));
let font = pick("0").and_then(Val::num).and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned();
Style {
font,
size: pick("1").and_then(Val::num).filter(|v| *v > 0.0).unwrap_or(12.0),
fill: if on("56") { Some(paint("53").unwrap_or([0.0, 0.0, 0.0, 1.0])) } else { None },
stroke: if matches!(pick("57"), Some(Val::Bool(true))) { paint("54") } else { None },
stroke_width: pick("63").and_then(Val::num).filter(|v| *v >= 0.0).unwrap_or(1.0),
}
}
}
impl Val {
fn as_dict(&self) -> Option<&BTreeMap<String, Val>> {
match self {
Val::Dict(d) => Some(d),
_ => None,
}
}
}
fn find_node<'a>(v: &'a Val, name: &str) -> Option<&'a Val> {
let mut out = vec![];
find_nodes(v, name, &mut out);
out.into_iter().next()
}
/// The typed nodes called `name` in `v`, in order, outermost first.
fn find_nodes<'a>(v: &'a Val, name: &str, out: &mut Vec<&'a Val>) {
match v {
Val::Dict(d) => {
if v.is_node(name) {
out.push(v);
}
for x in d.values() {
find_nodes(x, name, out);
}
}
Val::List(l) => {
for x in l {
find_nodes(x, name, out);
}
}
_ => {}
}
}
/// Where a story's first line starts: on the canvas of the app (what [`Story::place`] gives).
impl Story {
/// How many bytes of text the story has.
pub(super) fn len(&self) -> usize {
self.text.len()
}
/// The anchor of the story on the file's canvas: `(x, y)` in the units of the art, y up, given
/// the centre `(tx, ty)` of the file's template box.
pub(super) fn place(&self, template: (f64, f64)) -> (f64, f64) {
let [a, b, c, d, e, f] = self.matrix;
let (x, y) = (a * self.anchor.0 + c * self.anchor.1 + e, b * self.anchor.0 + d * self.anchor.1 + f);
(x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y)
}
/// Each line's characters and where it starts (in the units of the art, y up): where the page
/// draws the line when the type is shown.
pub(super) fn line_starts(&self, template: (f64, f64)) -> Vec<(String, (f64, f64))> {
let [a, b, c, d, e, f] = self.matrix;
let text: Vec<&str> = self.text.split(['\r', '\x03']).collect();
self.lines
.iter()
.enumerate()
.map(|(i, (lx, ly))| {
let (x, y) = (self.anchor.0 + lx, self.anchor.1 + ly);
let (x, y) = (a * x + c * y + e, b * x + d * y + f);
(text.get(i).copied().unwrap_or_default().to_string(), (x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y))
})
.collect()
}
/// The story as a text object whose anchor is at `at` on the document (`to_doc` takes the art's
/// units onto it), or `None` for type that is mirrored or has no size.
pub(super) fn node(&self, id: NodeId, template: (f64, f64), to_doc: Affine) -> Option<Node> {
let [a, b, c, d, ..] = self.matrix;
let scale = (a * d - b * c).abs().sqrt();
if !(scale.is_finite() && scale > 1e-6 && a * d - b * c > 0.0) {
return None;
}
let (x, y) = self.place(template);
let at = to_doc * Point::new(x, y);
let text: String = self.text.trim_end_matches('\r').chars().map(|c| if matches!(c, '\r' | '\x03') { '\n' } else { c }).collect();
if text.trim().is_empty() {
return None;
}
let leading = self.lines.windows(2).map(|w| (w[1].1 - w[0].1) * scale).find(|l| l.is_finite() && *l > 0.0);
let chars: Vec<char> = text.chars().collect();
let mut runs = vec![];
let mut from = 0usize;
let styles: Vec<&(usize, Style)> = self.runs.iter().collect();
for (n, st) in styles {
let to = from.saturating_add(*n).min(chars.len());
let piece: String = chars.get(from..to).unwrap_or_default().iter().collect();
from = to;
if piece.is_empty() {
continue;
}
runs.push(TextRun::new(piece, char_style(st, scale, leading)));
}
if from < chars.len() {
let last = self.runs.last().map(|r| &r.1)?;
runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::<String>(), char_style(last, scale, leading)));
}
if runs.is_empty() {
return None;
}
let mut t = TextObject::point(Point::ZERO, "", runs.first()?.style.clone());
t.runs = runs;
t.para = ParaStyle {
justify: match self.align {
Align::Left => Justify::Left,
Align::Center => Justify::Center,
Align::Right => Justify::Right,
},
..ParaStyle::default()
};
t.xf = Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]);
Some(Node::new(id, NodeKind::Text(Box::new(t))))
}
}
fn char_style(s: &Style, scale: f64, leading: Option<f64>) -> CharStyle {
let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style);
let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
let ink = |c: [f64; 4]| Paint::solid(Color::cmyk(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32));
CharStyle {
font_family: family,
font_style: style,
size: s.size * scale,
leading,
fill: s.fill.map_or(Paint::None, ink),
stroke: s.stroke.map_or(Paint::None, ink),
stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 },
..CharStyle::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn values_read_nested_dictionaries_lists_and_utf16_strings() {
let v = read(b" /1 << /0 [ 1 2.5 -3 ] /2 (\xfe\xff\x00h\x00i) /3 true /99 /Name >> /0 (a\\)b\\n)").unwrap();
let one = v.get("1").unwrap();
assert_eq!(one.get("0").unwrap().nums(), Some(vec![1.0, 2.5, -3.0]));
assert_eq!(one.get("2").unwrap().str(), Some("hi"));
assert_eq!(one.get("3"), Some(&Val::Bool(true)));
assert!(one.is_node("Name"));
assert_eq!(v.get("0").unwrap().str(), Some("a)b\n"));
}
#[test]
fn damaged_documents_are_not_read() {
for bad in [&b"/0 << /1"[..], b"/0 [ 1 2", b"/0 (abc", b"/0 nope", b"0 1"] {
assert!(read(bad).is_none(), "{}", String::from_utf8_lossy(bad));
}
let deep = format!("/0 {}", "[".repeat(200));
assert!(read(deep.as_bytes()).is_none());
}
}
+2 -2
View File
@@ -35,8 +35,8 @@ const MAX_DRAWN: usize = 1 << 24;
/// Samples taken of a shading function that isn't a plain interpolation.
const SHADING_SAMPLES: usize = 32;
const FAR_AWAY: &str = "objects far outside the page were left out";
const TOO_MUCH: &str = "the file draws more than VectorCraft reads: the rest was left out";
pub(super) const FAR_AWAY: &str = "objects far outside the page were left out";
pub(super) const TOO_MUCH: &str = "the file draws more than VectorCraft reads: the rest was left out";
const NESTED_PATTERNS: &str = "patterns nested too deeply are filled with mid-grey";
/// Deepest patterns and glyph procedures drawn inside each other's art.
pub(crate) const MAX_APART: u32 = 8;
+19 -6
View File
@@ -10,6 +10,7 @@
//! - `data`: data the program reads from itself through decoding filters, and images.
//! - `text`: fonts by name and type as point type; Type 3 fonts' glyphs drawn by their procedures.
//! - `preview`: the previews a file carries, for when its PostScript can't be read.
//! - `native`: the layers of an Illustrator file, from the editing copy of its art that it carries.
//!
//! Clipped art comes in as clipping groups. In a file in the legacy Illustrator format (versions
//! 3–8, with the prolog that defines its operators), the group operators `u` … `U` (nested) come in
@@ -24,10 +25,13 @@
//! error, the operator and the procedures it ran in; without a preview, what was drawn before the
//! error is kept (with that warning), else the file is refused.
mod ai;
mod ate;
mod data;
mod graphics;
mod interp;
mod lex;
mod native;
mod obj;
mod preview;
mod shading;
@@ -43,6 +47,8 @@ mod tests_generators;
mod tests_illustrator;
#[cfg(test)]
mod tests_images;
#[cfg(test)]
mod tests_native;
use std::sync::Arc;
@@ -55,6 +61,7 @@ use graphics::{GState, Out};
use interp::{Fault, Interp};
use obj::PsError;
pub use native::{ai_alone, is_loss, layered_ai};
pub use text::family_style;
/// The page of a PostScript file without a bounding box (US Letter).
@@ -182,7 +189,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, String> {
}
let drew = !out.drawn.is_empty();
let why = match (&result, drew) {
(Ok(()), true) => return Ok(finish(out)),
(Ok(()), true) => return Ok(native::layered(ps, finish(out))),
(Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(),
(Err(e), _) => reason(e, fault.as_ref()),
};
@@ -211,6 +218,16 @@ fn finish(mut out: Out) -> Imported {
if !out.shadings.is_empty() {
graphics::collapse(&mut children, &out.shadings);
}
let mut layer = Node::layer(out.doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
if let Some(c) = layer.children_mut() {
*c = children;
}
finish_with(out, vec![Arc::new(layer)])
}
/// The document of what `out` drew, as `layers`: the spot inks as spot swatches, in CMYK when
/// most process colours were.
fn finish_with(out: Out, layers: Vec<Arc<Node>>) -> Imported {
let mut doc = out.doc;
if out.cmyk > out.rgb {
doc.color_mode = ColorMode::Cmyk;
@@ -227,10 +244,6 @@ fn finish(mut out: Out) -> Imported {
doc.swatches.push(Swatch { paint: Paint::Pattern { pattern: name.clone(), xf: Affine::IDENTITY }, name, global: false, spot: false });
}
}
let mut layer = Node::layer(doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
if let Some(c) = layer.children_mut() {
*c = children;
}
doc.layers = vec![Arc::new(layer)];
doc.layers = layers;
Imported { document: doc, warnings: out.warnings, preview: false }
}
+847
View File
@@ -0,0 +1,847 @@
//! The layers of an Illustrator EPS or `.ai`.
//!
//! Besides the page it prints, such a file carries the app's own copy of the art: an EPS after its
//! `%%EOF`, a `.ai` in its `AIPrivateData` streams (see `ai` for the containers). It has what the
//! page doesn't: the layers with their names and options, the groups, compound paths and clipping
//! groups, the objects' names, the hidden objects and layers, the art outside the artboards and
//! every artboard. `ai` reads that structure into a document, with the colours, gradients,
//! transparency and images of the art.
//!
//! Text objects are slots in that document: their characters, fonts and places are in the file's
//! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot
//! whose text is where the page has it. Type that doesn't show (a hidden layer or object, or off
//! the page) is made from the text document; where that can't be read (area type, type on a path)
//! it's left out, with a warning.
//!
//! What a layer that shows has that `ai` doesn't read makes the file come in as its page, as it did
//! before; on a layer that doesn't show it is left out of that layer, with a warning. The page is
//! also the safety net: when the layers' art looks different from it, the page is used.
//!
//! Where a file doesn't match what this module expects, it is left alone and its page is imported.
use std::collections::BTreeSet;
use std::sync::Arc;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use super::Imported;
use super::ai::{self, Structure, slot_of};
use super::ate::{Story, Texts};
/// The notes that say the import left out something the file has (see [`is_loss`]).
const TEXT_LEFT_OUT: &str = "text objects on hidden layers or hidden objects couldn't be read, so they are left out";
/// The same for a file without a page to take shown type from.
const TYPE_LEFT_OUT: &str = "text objects of kinds VectorCraft doesn't read from the file's text document yet are left out";
const ART_LEFT_OUT: &str = "hidden layers have art this can't read";
const LAYERS_UNREAD: &str = "the file's layers weren't read";
/// Does this import note say that the file has something the document doesn't (hidden text, art or
/// layers that could not be read)? Writing the document over the file would lose it for good.
pub fn is_loss(note: &str) -> bool {
note.ends_with(TEXT_LEFT_OUT)
|| note.ends_with(TYPE_LEFT_OUT)
|| [ART_LEFT_OUT, LAYERS_UNREAD, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
}
/// Most text objects in the layers, and most pieces of type in one place on the page, that are matched
/// to each other (more than this and the layers aren't used).
const MAX_SLOTS: usize = 5000;
const MAX_PIECES: usize = 2000;
/// Most bytes of text in the text objects of the layers (a story named twice counts twice).
const MAX_TEXT: usize = 16 << 20;
/// Most pairs of a text object and a piece of the page's type compared.
const MAX_PAIRS: usize = 4_000_000;
/// `names` for a note: the first few, and how many more.
fn few(names: BTreeSet<String>) -> String {
const SHOWN: usize = 5;
let more = names.len().saturating_sub(SHOWN);
let mut list: Vec<String> = names.into_iter().take(SHOWN).collect();
if more > 0 {
list.push(format!("{more} more"));
}
list.join(", ")
}
/// A text slot of the layers.
struct SlotInfo {
story: Option<u32>,
/// It, its layer and the objects round it show.
shown: bool,
}
/// The text slots of the layers and objects, in order.
fn collect_slots(nodes: &[Arc<Node>], shown: bool, out: &mut Vec<SlotInfo>) {
for n in nodes {
let shown = shown && n.visible;
if let Some(story) = slot_of(n.name.as_deref()) {
out.push(SlotInfo { story, shown });
} else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) {
collect_slots(c, shown, out);
}
}
}
/// How far from a text object's own extent the strokes drawn around it reach (points).
const OUTLINE_REACH: f64 = 6.0;
/// Does `n` look like part of a text object's outline: a path with a stroke, inside `around`?
fn outlines(n: &Node, around: Rect) -> bool {
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
&& n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.width > 0.0))
&& n.visual_bounds().is_some_and(|b| {
let a = around.inflate(OUTLINE_REACH, OUTLINE_REACH);
b.x0 >= a.x0 && b.y0 >= a.y0 && b.x1 <= a.x1 && b.y1 <= a.y1
})
}
/// How far apart (points) the baselines of two text nodes may be for them to be on one line.
const SAME_LINE: f64 = 0.5;
/// Where `n`'s baseline lies across the direction it runs in (points), if it is text.
fn baseline(n: &Node) -> Option<f64> {
let NodeKind::Text(t) = &n.kind else { return None };
let [a, b, _, _, e, f] = t.xf.as_coeffs();
let len = a.hypot(b);
(len > 1e-9).then(|| (a * f - b * e) / len)
}
/// The text objects the page paints, each as the run of nodes that make it up (its text, once for
/// each fill and stroke it has, with the paths of its strokes), in painting order: those that
/// show in `shown`, those of hidden layers and objects (a PDF has them) in `hidden`.
fn text_objects(nodes: &[Arc<Node>], on: bool, shown: &mut Vec<Vec<Arc<Node>>>, hidden: &mut Vec<Vec<Arc<Node>>>) {
let is_text = |n: &Node| matches!(n.kind, NodeKind::Text(_));
let (mut i, mut free) = (0, 0);
while let Some(n) = nodes.get(i) {
if !is_text(n) {
if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) {
text_objects(c, on && n.visible, shown, hidden);
free = i + 1;
}
i += 1;
continue;
}
let mut bounds = n.visual_bounds().unwrap_or_default();
let (mut start, mut end) = (i, i);
while start > free
&& let Some(p) = nodes.get(start - 1)
&& outlines(p, bounds)
{
start -= 1;
bounds = bounds.union(p.visual_bounds().unwrap_or_default());
}
while let Some(next) = nodes.get(end + 1)
&& let Some(b) = next.visual_bounds()
&& (is_text(next)
&& !b.intersect(bounds.inflate(OUTLINE_REACH, OUTLINE_REACH)).is_zero_area()
&& baseline(next)
.zip(nodes.get(start..=end).unwrap_or_default().iter().find_map(|m| baseline(m)))
.is_some_and(|(x, y)| (x - y).abs() <= SAME_LINE)
|| outlines(next, bounds))
{
end += 1;
bounds = bounds.union(b);
}
let object = nodes.get(start..=end).unwrap_or_default().to_vec();
let into = if on && n.visible { &mut *shown } else { &mut *hidden };
into.push(object);
i = end + 1;
free = i;
}
}
/// `n` and what it holds with ids of `doc`.
fn reid(doc: &mut Document, n: &Arc<Node>) -> Arc<Node> {
let mut n = n.as_ref().clone();
n.id = doc.alloc_id();
if let Some(children) = n.children_mut() {
for c in children.iter_mut() {
*c = reid(doc, c);
}
}
Arc::new(n)
}
/// The page's text objects given to each of `slots` text slots: in painting order, and when there
/// are more of one than of the other (a label may be a few text objects, or one with several
/// strokes), each goes where the share of the order it is at falls.
fn assign(objects: &[Vec<Arc<Node>>], slots: usize) -> Vec<Vec<&[Arc<Node>]>> {
let mut given: Vec<Vec<&[Arc<Node>]>> = vec![vec![]; slots];
for (j, o) in objects.iter().enumerate() {
if let Some(at) = given.get_mut(((2 * j + 1) * slots / (2 * objects.len())).min(slots.saturating_sub(1))) {
at.push(o);
}
}
given
}
/// What goes into a text slot.
enum Content {
/// Text objects (with the strokes round them) of the page.
Page(Vec<Arc<Node>>),
/// A text object made from the file's text document.
Made(Box<Node>),
/// Nothing the file lets this read.
Empty,
/// Its type is on the page with another text object's; there is nothing to put here.
Merged,
}
/// Replace the text slots in `nodes` with what `content` has for each, in order (a slot given none is
/// left out): the page's type takes its place as it is, made type keeps the slot's name. `empty`
/// counts those left out.
fn fill_slots(
doc: &mut Document,
nodes: &mut Vec<Arc<Node>>,
content: &mut std::vec::IntoIter<Content>,
names: &std::collections::HashMap<NodeId, String>,
empty: &mut usize,
) {
let mut i = 0;
while i < nodes.len() {
let Some(n) = nodes.get(i) else { break };
if slot_of(n.name.as_deref()).is_some() {
let visible = n.visible;
let name = names.get(&n.id);
let replacement = match content.next() {
Some(Content::Page(parts)) if !parts.is_empty() => {
// The page's type (and the strokes round it) take the slot's place as they are.
let parts: Vec<Arc<Node>> = parts
.iter()
.map(|p| {
let mut p = reid(doc, p);
if !visible {
Arc::make_mut(&mut p).visible = false;
}
if let Some(name) = name.filter(|_| matches!(p.kind, NodeKind::Text(_))) {
Arc::make_mut(&mut p).name = Some(name.clone());
}
p
})
.collect();
let n = parts.len();
nodes.splice(i..=i, parts);
i += n;
continue;
}
Some(Content::Made(mut text)) => {
text.id = doc.alloc_id();
text.visible = visible;
text.name = Some(format!("{MADE}{}", name.map_or("", String::as_str)));
Some(*text)
}
Some(Content::Merged) => {
nodes.remove(i);
continue;
}
_ => None,
};
match replacement {
Some(node) => {
if let Some(slot) = nodes.get_mut(i) {
*slot = Arc::new(node);
}
i += 1;
}
None => {
*empty += 1;
nodes.remove(i);
}
}
} else {
if n.children().is_some()
&& !matches!(n.kind, NodeKind::Compound { .. })
&& let Some(c) = nodes.get_mut(i).map(Arc::make_mut).and_then(Node::children_mut)
{
fill_slots(doc, c, content, names, empty);
}
i += 1;
}
}
}
/// How far, in points, the page's type may be from where the file's text document puts it for the
/// two to be the same type.
const SAME_PLACE: f64 = 0.75;
/// Its letters and digits, which is what is compared of type (the page may spell a mark another way).
fn letters(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).collect()
}
/// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it
/// (the page splits a line where its kerning or its styles change), on that line's baseline and
/// after its start? How far from where the line starts it is, if so.
fn same_type(n: &Node, lines: &[(String, Point)]) -> Option<f64> {
let NodeKind::Text(t) = &n.kind else { return None };
let have = letters(&t.plain_text());
let [a, b, _, _, e, f] = t.xf.as_coeffs();
let len = a.hypot(b);
if have.is_empty() || len < 1e-9 {
return None;
}
let (ux, uy) = (a / len, b / len);
// About how far a character of it advances, to say how far along a line a piece may be.
let advance = n.visual_bounds().map_or(0.0, |r| r.width().max(r.height())) / have.len() as f64;
lines
.iter()
.filter_map(|(text, at)| {
let line = letters(text);
let skipped = line.find(&have)?;
if skipped == 0 {
return Some(((at.x - e).powi(2) + (at.y - f).powi(2)).sqrt()).filter(|d| *d <= SAME_PLACE);
}
let (dx, dy) = (e - at.x, f - at.y);
let (along, across) = (dx * ux + dy * uy, (dx * uy - dy * ux).abs());
((have.len() > 1 || skipped + 1 == line.len())
&& across <= SAME_PLACE
&& along >= -SAME_PLACE
&& along <= line.len() as f64 * advance * 2.0 + 4.0)
.then_some(across + 0.001 * along)
})
.min_by(f64::total_cmp)
}
/// The page draws a line in pieces where its kerning or styles change ("swe", "ep"), and the file
/// has it as one line of one text object: the pieces that on one baseline add up to a line of
/// `lines` become one text (each keeping its own style, the line's spaces between them).
fn join_pieces(parts: &mut Vec<Arc<Node>>, lines: &[(String, Point)]) {
let text_of = |n: &Arc<Node>| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None };
let texts: Vec<usize> = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect();
// A page with this many pieces of type in one place isn't one of lines of a story.
if texts.len() > MAX_PIECES {
return;
}
let mut taken = vec![false; parts.len()];
// The first piece of each line to join, the others, and the spaces to put after each piece.
let mut joins: Vec<(usize, Vec<usize>, Vec<String>)> = vec![];
for &i in &texts {
if taken.get(i).copied().unwrap_or(true) {
continue;
}
let Some(line) = parts.get(i).and_then(|n| baseline(n)) else { continue };
let group: Vec<usize> = texts
.iter()
.copied()
.filter(|j| {
!taken.get(*j).copied().unwrap_or(true) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE)
})
.collect();
for j in &group {
if let Some(t) = taken.get_mut(*j) {
*t = true;
}
}
let pieces: Vec<String> = group.iter().filter_map(|j| parts.get(*j).and_then(text_of)).collect();
let places: Vec<(f64, f64)> = group
.iter()
.filter_map(|j| parts.get(*j))
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((t.xf.as_coeffs()[4], t.xf.as_coeffs()[5])) } else { None })
.collect();
let distinct = places.iter().enumerate().all(|(a, p)| places.iter().skip(a + 1).all(|q| (p.0 - q.0).hypot(p.1 - q.1) > 0.01));
let joined: String = pieces.iter().map(|p| letters(p)).collect();
if group.len() < 2 || !distinct || joined.is_empty() {
continue;
}
let Some((whole, _)) = lines.iter().find(|(text, _)| letters(text) == joined) else { continue };
// Where the line has spaces after a piece's last character that the page doesn't draw.
let line_chars: Vec<char> = whole.chars().collect();
let mut at = 0;
let mut gaps = vec![];
for (k, piece) in pieces.iter().enumerate() {
for _ in piece.chars().filter(|c| !c.is_whitespace()) {
while line_chars.get(at).is_some_and(|c| c.is_whitespace()) {
at += 1;
}
at += 1;
}
let space: String = line_chars.iter().skip(at).take_while(|c| c.is_whitespace()).collect();
let next = pieces.get(k + 1);
let drawn = piece.ends_with(char::is_whitespace) || next.is_none_or(|n| n.starts_with(char::is_whitespace));
gaps.push(if drawn { String::new() } else { space });
}
if at <= line_chars.len() {
joins.push((group[0], group[1..].to_vec(), gaps));
}
}
let mut gone: BTreeSet<usize> = BTreeSet::new();
for (first, rest, gaps) in &joins {
let mut runs: Vec<Vec<vectorcraft_doc::TextRun>> = [first]
.into_iter()
.chain(rest)
.filter_map(|j| parts.get(*j))
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.clone()) } else { None })
.collect();
for (r, gap) in runs.iter_mut().zip(gaps) {
if let Some(last) = r.last_mut() {
last.text.push_str(gap);
}
}
if let Some(node) = parts.get_mut(*first).map(Arc::make_mut)
&& let NodeKind::Text(t) = &mut node.kind
{
t.runs = runs.into_iter().flatten().collect();
// The layout cached for the first piece is not this text's.
t.cached_bounds = None;
t.cached_baselines.clear();
}
gone.extend(rest);
}
// The pieces that went into a text, removed once the joins are done: removing as each is made
// would move the places of the pieces that the others still name.
let mut keep = 0;
parts.retain(|_| {
keep += 1;
!gone.contains(&(keep - 1))
});
}
/// The box round `parts`.
fn extent(parts: &[Arc<Node>]) -> Option<Rect> {
parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))
}
/// What goes into each of `infos`' slots: the page's type that the file's text document puts where the
/// page has it, else type made from the text document, else (for a file whose text document can't say)
/// the page's type in painting order.
fn plan(
infos: &[SlotInfo],
stories: &[Option<Arc<Story>>],
template: Option<(f64, f64)>,
to_doc: Affine,
pages: [&[Vec<Arc<Node>>]; 2],
warn: &mut Vec<String>,
) -> Vec<Content> {
let [shown, hidden] = pages;
let lines: Vec<Option<Vec<(String, Point)>>> = stories
.iter()
.map(|s| {
let (s, t) = (s.as_ref()?, template?);
Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect())
})
.collect();
// The page's objects that are where a shown slot's lines are, nearest first.
let mut pairs: Vec<(f64, usize, usize)> = vec![];
for (k, info) in infos.iter().enumerate() {
let Some(l) = lines.get(k).and_then(Option::as_ref).filter(|_| info.shown) else { continue };
for (j, object) in shown.iter().enumerate() {
if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) {
pairs.push((d, k, j));
}
}
}
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut owner: Vec<Option<usize>> = vec![None; shown.len()];
for (_, k, j) in pairs {
if let Some(o) = owner.get_mut(j).filter(|o| o.is_none()) {
*o = Some(k);
}
}
// The file's text document is trusted where it agrees with the page about something, or where the
// page has no type to disagree with.
let trusted = owner.iter().any(Option::is_some) || shown.is_empty();
let readable = |k: usize| trusted && lines.get(k).is_some_and(Option::is_some);
let mut content: Vec<Content> = infos.iter().map(|_| Content::Empty).collect();
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
for (j, object) in shown.iter().enumerate() {
match owner.get(j).copied().flatten().filter(|_| trusted) {
Some(k) => match content.get_mut(k) {
Some(Content::Page(p)) => p.extend(object.iter().cloned()),
Some(c) => *c = Content::Page(object.clone()),
None => {}
},
None => spare.push(object.clone()),
}
}
for (k, c) in content.iter_mut().enumerate() {
if let (Content::Page(parts), Some(Some(l))) = (c, lines.get(k)) {
join_pieces(parts, l);
}
}
// Type of the text document where the page has none: hidden type, and shown type off the page.
for (k, c) in content.iter_mut().enumerate() {
if matches!(c, Content::Empty)
&& readable(k)
&& let (Some(Some(story)), Some(t)) = (stories.get(k), template)
&& let Some(node) = story.node(NodeId(0), t, to_doc)
{
*c = Content::Made(Box::new(node));
}
}
// What is left of the page goes by painting order into the shown slots the file can't say.
let loose: Vec<usize> = infos
.iter()
.enumerate()
.filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty)) && !readable(*k))
.map(|(k, _)| k)
.collect();
if loose.is_empty() {
// Type of the page that no text object says it owns goes with the nearest text object that has
// type of the page; with none, it is left out.
let mut left_out = false;
for object in &spare {
let (centre, near) = (extent(object).map(|r| r.center()), |c: &Content| match c {
Content::Page(parts) => extent(parts).map(|r| r.center()),
_ => None,
});
let nearest = content
.iter_mut()
.filter_map(|c| near(c).zip(centre).map(|(p, q)| ((p.x - q.x).hypot(p.y - q.y), c)))
.min_by(|a, b| a.0.total_cmp(&b.0));
match nearest {
Some((_, Content::Page(parts))) => parts.extend(object.iter().cloned()),
_ => left_out = true,
}
}
if !spare.is_empty() {
warn.push(
if left_out {
"some of the page's type has no text object in the layers, so it is left out"
} else {
"some of the page's type has no text object of its own in the layers, so it is with another's"
}
.into(),
);
}
} else {
for (slot, parts) in loose.iter().zip(assign(&spare, loose.len())) {
if let Some(c) = content.get_mut(*slot) {
// A text object the page shows as part of another's has nothing to hold of its own.
*c = if parts.is_empty() && !spare.is_empty() {
Content::Merged
} else {
Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect())
};
}
}
if !spare.is_empty() && spare.len() != loose.len() {
warn.push("the text is on the layers in the order the page paints it, so a text object may be in another group than it was".into());
}
}
// The same for the slots that don't show.
let unread: Vec<usize> =
infos.iter().enumerate().filter(|(k, i)| !i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect();
for (slot, parts) in unread.iter().zip(assign(hidden, unread.len())) {
if let Some(c) = content.get_mut(*slot) {
*c = Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect());
}
}
content
}
/// `doc` with its layers that don't print hidden, when it has any.
fn as_printed(doc: &Document) -> Option<Document> {
fn hide(nodes: &mut [Arc<Node>]) -> bool {
let mut any = false;
for n in nodes {
if matches!(n.kind, NodeKind::Layer { printable: false, .. }) && n.visible {
Arc::make_mut(n).visible = false;
any = true;
} else if matches!(n.kind, NodeKind::Layer { .. })
&& let Some(c) = Arc::make_mut(n).children_mut()
{
any |= hide(c);
}
}
any
}
let mut d = doc.clone();
hide(&mut d.layers).then_some(d)
}
/// The lightness (0 to 255, Rec. 601 weights) of a premultiplied pixel seen on white.
fn lightness(p: &[u8; 4]) -> u8 {
let on_white = |c: u8| u32::from(c) + 255 - u32::from(p[3]).min(255);
((on_white(p[0]) * 299 + on_white(p[1]) * 587 + on_white(p[2]) * 114) / 1000).min(255) as u8
}
/// How much two documents draw differently (0 to 1), `a` on `ra` and `b` on `rb` (rectangles of
/// the same size), each seen on white. Pixels are compared by lightness: the page of an RGB
/// document is written in the colours' CMYK equivalents, and what the comparison looks for is art
/// that is missing or out of place.
fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> f64 {
// The page doesn't print what isn't printed.
let printed = as_printed(b);
let b = printed.as_ref().unwrap_or(b);
let scale = (400.0 / ra.width().max(ra.height()).max(1.0)).min(4.0);
let mut r = vectorcraft_render::Renderer::new();
// Drawn on nothing and then seen on white: blending modes have no page to blend with, as in
// the app that wrote the file.
let (x, y) = (r.render_region(a, ra, scale, false), r.render_region(b, rb, scale, false));
if x.width != y.width || x.height != y.height {
return 1.0;
}
let differing =
x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).filter(|(p, q)| lightness(p).abs_diff(lightness(q)) > 48).count();
differing as f64 / (f64::from(x.width) * f64::from(x.height)).max(1.0)
}
/// More of the page than this differing between the layers' art and the page's own, and the
/// layers aren't used.
const MAX_DIFFERENCE: f64 = 0.05;
/// More than this and the import says so.
const NOTABLE_DIFFERENCE: f64 = 0.002;
/// Which box of the editing copy is the page.
#[derive(Clone, Copy)]
enum Page {
/// An EPS's page is its art's bounding box.
Art,
/// A `.ai` is read on its (first and only) artboard.
Artboard,
}
/// The page (`visible`, the file read as its page) and the place of its area in the layers'
/// document.
struct Frame<'a> {
page: &'a Document,
/// The page's area in `page`.
page_rect: Rect,
/// The same area in the layers' document.
rect: Rect,
}
/// The document of the editing copy `data`, with the text (and strokes round it) of `visible`, the
/// file read as its page, put in its text objects → the document and its notes. Without a page
/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and
/// nothing is compared.
fn build(data: &[u8], visible: Option<&Document>, page: Page) -> Result<(Document, Vec<String>), String> {
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data)?;
let (mut shown, mut hidden) = (vec![], vec![]);
let mut frame = None;
if let Some(visible) = visible {
let art_box = match page {
Page::Art => bbox.map(|[a, b, c, d]| Rect::new(a, b, c, d)).ok_or("its editing data doesn't say where its art is")?,
Page::Artboard => artboard.ok_or("its editing data doesn't say where its artboard is")?,
};
let page_rect = visible.artboards.first().map(|a| a.rect).ok_or("it has no page")?;
if (art_box.width() - page_rect.width()).abs() > 0.5 || (art_box.height() - page_rect.height()).abs() > 0.5 {
return Err("its editing data is for a different page".into());
}
// The page's area in the layers' document, and the page's type moved there.
let rect = to_doc.transform_rect_bbox(art_box);
let shift = Affine::translate(Vec2::new(rect.x0 - page_rect.x0, rect.y0 - page_rect.y0));
for l in &visible.layers {
if let Some(c) = l.children() {
text_objects(c, l.visible, &mut shown, &mut hidden);
}
}
for object in shown.iter_mut().chain(hidden.iter_mut()) {
for n in object.iter_mut() {
Arc::make_mut(n).transform(shift, false);
}
}
frame = Some(Frame { page: visible, page_rect, rect });
}
let mut infos = vec![];
collect_slots(&doc.layers, true, &mut infos);
if infos.iter().all(|i| !i.shown) && !shown.is_empty() {
return Err("its page has text, and its layers have no text objects to put it in".into());
}
// The page's type is compared with every text object of the layers: that has its limits.
if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS {
return Err("it has too many text objects".into());
}
let texts = Texts::read(data);
// Each story is read once, however many text objects name it.
let mut read: std::collections::BTreeMap<u32, Option<Arc<Story>>> = Default::default();
let stories: Vec<Option<Arc<Story>>> = infos
.iter()
.map(|i| {
let n = i.story?;
read.entry(n).or_insert_with(|| texts.as_ref().and_then(|t| t.story(n as usize)).map(Arc::new)).clone()
})
.collect();
if stories.iter().flatten().map(|s| s.len()).sum::<usize>() > MAX_TEXT {
return Err("it has too much text".into());
}
let template = template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
let mut notes = warnings;
let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes);
let mut empty = 0;
let mut layers = std::mem::take(&mut doc.layers);
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty);
doc.layers = layers;
if empty > 0 {
notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT }));
}
if !hidden_unread.is_empty() {
notes.push(format!("{ART_LEFT_OUT} ({}), left out of them", few(hidden_unread)));
}
match frame {
Some(frame) => {
let unseen = prune_unseen(&frame, &mut doc);
if unseen > 0 {
notes.push(format!("{unseen} {UNSEEN_TEXT}"));
}
notes.extend(compare(&frame, &doc)?);
}
None => unmark(&mut doc.layers, true, &mut vec![]),
}
Ok((doc, notes))
}
/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's
/// text document.
const MADE: &str = "\u{1}made";
/// How many such objects are weighed against the page (each takes a render of the page's size).
const MAX_WEIGHED: usize = 12;
/// The text objects the file keeps that the page doesn't draw.
const UNSEEN_TEXT: &str = "text objects that the file keeps but its page doesn't draw (what is left of type turned to outlines) are left out";
/// How much more of the page (0 to 1) the layers may differ without a text object and still look no worse.
const UNSEEN_MARGIN: f64 = 5e-5;
/// Clear the marks in `nodes` and say which marked objects show, by id.
fn unmark(nodes: &mut [Arc<Node>], shown: bool, found: &mut Vec<(NodeId, Option<Rect>)>) {
for n in nodes {
let shown = shown && n.visible;
if let Some(name) = n.name.as_deref().and_then(|m| m.strip_prefix(MADE)) {
if shown {
found.push((n.id, n.visual_bounds()));
}
let name = (!name.is_empty()).then(|| name.to_string());
Arc::make_mut(n).name = name;
} else if !matches!(n.kind, NodeKind::Compound { .. })
&& let Some(c) = Arc::make_mut(n).children_mut()
{
unmark(c, shown, found);
}
}
}
/// Take object `id` out of `nodes` (`hide`: leave it, hidden); whether it was there.
fn take_out(nodes: &mut Vec<Arc<Node>>, id: NodeId, hide: bool) -> bool {
if let Some(i) = nodes.iter().position(|n| n.id == id) {
if hide {
if let Some(n) = nodes.get_mut(i) {
Arc::make_mut(n).visible = false;
}
} else {
nodes.remove(i);
}
return true;
}
nodes.iter_mut().any(|n| !matches!(n.kind, NodeKind::Compound { .. }) && Arc::make_mut(n).children_mut().is_some_and(|c| take_out(c, id, hide)))
}
/// The text objects made from the file's text document that show on the page's area, where the page
/// has no type to match them, and that the layers look no worse without, are not drawn by the app
/// that wrote the file (which paints every shown text object; it keeps the type of what was turned
/// to outlines): they are taken out. How many.
fn prune_unseen(frame: &Frame<'_>, doc: &mut Document) -> usize {
let mut found = vec![];
unmark(&mut doc.layers, true, &mut found);
let rect = frame.rect;
if found.is_empty() || found.len() > MAX_WEIGHED {
return 0;
}
let mut with = difference(frame.page, frame.page_rect, doc, rect);
let mut removed = 0;
for (id, bounds) in found {
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
continue;
}
let mut without = doc.clone();
if !take_out(&mut without.layers, id, true) {
continue;
}
let d = difference(frame.page, frame.page_rect, &without, rect);
if d - with <= UNSEEN_MARGIN {
take_out(&mut doc.layers, id, false);
with = d;
removed += 1;
}
}
removed
}
/// Do the layers' art and the page's own look alike? An error says they don't; `Ok` has the note
/// to give when they differ a little.
fn compare(frame: &Frame<'_>, layered: &Document) -> Result<Option<String>, String> {
let diff = difference(frame.page, frame.page_rect, layered, frame.rect);
if diff > MAX_DIFFERENCE {
return Err(format!("the art on them differs from the page's in {:.0}% of it", diff * 100.0));
}
Ok((diff > NOTABLE_DIFFERENCE).then(|| {
format!(
"the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)",
diff * 100.0
)
}))
}
/// The EPS `visible` (read as its page) read through its editing copy instead: its layers (the
/// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the
/// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it
/// has one that can't be used, `visible` with a warning that says why.
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
let Some(data) = ai::eps_data(ps) else { return visible };
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art));
match result {
Ok((document, mut warnings)) => {
for w in &visible.warnings {
if !warnings.contains(w) {
warnings.push(w.clone());
}
}
Imported { document, warnings, preview: false }
}
Err(why) => {
let mut v = visible;
v.warnings.push(format!("{LAYERS_UNREAD} ({why}): it comes in as its page, in one layer"));
v
}
}
}
/// The `.ai` `visible` (read as its PDF part) read through its editing copy (`private`, the joined
/// `AIPrivateData` streams) instead, as [`layered`] reads an EPS: with the layers and objects of the
/// file, its artboards, and the art that lies outside them, which its PDF part doesn't have.
/// `warnings` are the notes of reading the PDF part; the notes returned are those, and the layers'.
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
let Ok(data) = ai::decode_private(private) else { return (visible, warnings) };
if !data.windows(15).any(|w| w == b"%AI5_BeginLayer") {
return (visible, warnings);
}
match build(&data, Some(&visible), Page::Artboard) {
Ok((done, notes)) => {
let mut all = warnings;
all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::<Vec<_>>());
(done, all)
}
Err(why) => {
let mut all = warnings;
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
(visible, all)
}
}
}
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
let data = ai::decode_private(private)?;
build(&data, None, Page::Artboard)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::{CharStyle, TextObject};
fn piece(text: &str, x: f64, y: f64) -> Arc<Node> {
Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(x, y), text, CharStyle::default())))))
}
/// Lines that the page draws in pieces, the pieces of one between those of the other, are each
/// joined, and only their own pieces go.
#[test]
fn interleaved_lines_are_joined_without_taking_each_others_pieces() {
let mut parts = vec![piece("He", 0.0, 0.0), piece("ab", 0.0, -20.0), piece("cd", 16.0, -20.0), piece("llo", 20.0, 0.0)];
let lines = [("Hello".to_string(), Point::new(0.0, 0.0)), ("abcd".to_string(), Point::new(0.0, -20.0))];
join_pieces(&mut parts, &lines);
let texts: Vec<String> = parts.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
assert_eq!(texts, ["Hello", "abcd"]);
}
}
+20
View File
@@ -36,3 +36,23 @@ fn postscript_names_resolve_to_the_available_family() {
});
assert_eq!(styles, [("MyFaceYaHei 3".to_string(), "Bold".to_string())]);
}
#[test]
fn reencoded_copies_of_a_font_name_the_font() {
// A program that re-encodes a font sets the copy under `<subset>+<name>*<n>`.
assert_eq!(crate::family_style("KBKNNM+RollerBabyBV*1"), ("Roller Baby BV".to_string(), "Regular".to_string()));
assert_eq!(crate::family_style("ABCDEF+Helvetica-Bold*12"), ("Helvetica".to_string(), "Bold".to_string()));
// Only `*` and digits at the end go; other names are as they were.
assert_eq!(crate::family_style("Odd*Name").0, "Odd*Name");
assert_eq!(crate::family_style("*1").0, "*1");
}
#[test]
fn abbreviated_styles_in_postscript_names_are_spelled_out() {
// A style word PostScript names shorten is the style the installed font names in full.
assert_eq!(crate::family_style("AkzidenzGroteskPro-Md"), ("Akzidenz Grotesk Pro".to_string(), "Medium".to_string()));
assert_eq!(crate::family_style("AkzidenzGroteskPro-XBdCnIt").1, "Extra Bold Condensed Italic");
assert_eq!(crate::family_style("AkzidenzGroteskPro-LightCn").1, "Light Condensed");
// Whole words stay as they were.
assert_eq!(crate::family_style("Helvetica-BoldOblique").1, "Bold Oblique");
}
+544
View File
@@ -0,0 +1,544 @@
//! The layers of an Illustrator EPS and `.ai`: read from the editing copy of the art the file
//! carries (see `native`). The files here are small ones written by hand for the tests, with the
//! operators seen in files their users own.
use vectorcraft_doc::{Document, LayerColor, Node, NodeKind};
use crate::import::import;
/// `data` as ASCII85, up to the `~>`.
fn ascii85(data: &[u8]) -> String {
let mut out = String::new();
for chunk in data.chunks(4) {
let v = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
let mut d = [0u8; 5];
let mut x = v;
for c in d.iter_mut().rev() {
*c = (x % 85) as u8 + b'!';
x /= 85;
}
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
}
out.push_str("~>");
out
}
fn zstd(data: &str) -> Vec<u8> {
ruzstd::encoding::compress_to_vec(data.as_bytes(), ruzstd::encoding::CompressionLevel::Fastest)
}
/// The editing copy of a file with `layers` (made by [`layer`]), 100 × 100 points.
fn native(layers: &str) -> String {
format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI5_NumLayers: 2\n{layers}%%Trailer\n"
)
}
/// A layer as the format writes it.
fn layer(name: &str, visible: bool, body: &str) -> String {
format!("%AI5_BeginLayer\n{} 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n{body}\nLB\n%AI5_EndLayer--\n", u8::from(visible))
}
/// A yellow square of 4 points at (`x`, `y`) in the format's operators.
fn square(x: u32, y: u32) -> String {
format!("0 0 1 0 k\n{x} {y} m\n{} {y} L\n{} {} L\n{x} {} L\nf", x + 4, x + 4, y + 4, y + 4)
}
/// An EPS whose page draws `page` and whose private data is `native`.
fn eps(page: &str, native: &str) -> Vec<u8> {
let stream = ascii85(&zstd(native));
let lines: Vec<String> = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n",
lines.join("\n")
)
.into_bytes()
}
/// The page's drawing of the square at (`x`, `y`).
fn page_square(x: u32, y: u32) -> String {
format!("0 0 1 0 setcmykcolor {x} {y} moveto {} {y} lineto {} {} lineto {x} {} lineto closepath fill", x + 4, x + 4, y + 4, y + 4)
}
fn names(d: &Document) -> Vec<String> {
d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect()
}
fn kinds(l: &Node) -> Vec<&'static str> {
l.children()
.into_iter()
.flatten()
.map(|n| match &n.kind {
NodeKind::Group { clip: true, children } => {
["clip[0]", "clip[1]", "clip[2]", "clip[3]"].get(children.len()).copied().unwrap_or("clip[many]")
}
NodeKind::Group { children, .. } => {
["group[0]", "group[1]", "group[2]", "group[3]"].get(children.len()).copied().unwrap_or("group[many]")
}
NodeKind::Compound { children, .. } => {
["compound[0]", "compound[1]", "compound[2]", "compound[3]"].get(children.len()).copied().unwrap_or("compound[many]")
}
NodeKind::Path { clipping: true, .. } => "clippath",
NodeKind::Path { .. } => "path",
NodeKind::Text(_) => "text",
_ => "other",
})
.collect()
}
#[test]
fn layers_come_in_with_their_names_colours_and_hidden_state() {
let art = native(&(layer("Back", true, &square(10, 10)) + &layer("Hidden", false, &square(50, 50))));
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let d = &r.document;
assert_eq!(names(d), ["Back", "Hidden"], "{:?}", r.warnings);
assert_eq!([d.layers[0].visible, d.layers[1].visible], [true, false]);
// What is on a hidden layer stays: the page doesn't have it.
assert_eq!(kinds(&d.layers[1]), ["path"]);
// Colour 0 of the layer colours (light blue), by its place in the list.
assert!(matches!(&d.layers[0].kind, NodeKind::Layer { color: LayerColor::Preset(0), .. }));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
// The page is where the page's art was.
let b = d.layers[0].children().unwrap()[0].visual_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 86.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn sublayers_groups_compounds_clip_groups_and_hidden_objects_come_in() {
let body = [
"u".to_string(),
square(10, 10),
"U".into(),
"*u".into(),
square(20, 10),
square(30, 10),
"*U".into(),
// A clipping group: its objects, then its clip path.
"q".into(),
square(40, 10),
"50 10 m 60 10 L 60 20 L 50 20 L h W f".into(),
"Q".into(),
// Hidden until the next `Xw`.
"1 Xw".into(),
"u".into(),
square(70, 10),
"U".into(),
"0 Xw".into(),
]
.join("\n");
let sub = layer("Inner", true, &square(80, 10));
let art = native(&layer("Outer", true, &format!("{body}\n{sub}")));
// The page has what shows: the clip group's square is outside its clip path.
let page = [10, 20, 30, 80].map(|x| page_square(x, 10)).join("\n");
let r = import(&eps(&page, &art)).unwrap();
let outer = &r.document.layers[0];
assert_eq!(kinds(outer), ["group[1]", "compound[2]", "clip[2]", "group[1]", "other"]);
let children = outer.children().unwrap();
assert!(children[0].visible && !children[3].visible, "the hidden object is hidden, not left out");
// The clip path comes first in its group.
assert!(matches!(children[2].children().unwrap()[0].kind, NodeKind::Path { clipping: true, .. }));
// The sublayer is a layer of the layer.
assert!(matches!(&children[4].kind, NodeKind::Layer { .. }));
assert_eq!(children[4].name.as_deref(), Some("Inner"));
}
#[test]
fn what_the_layers_cant_say_keeps_the_page_when_it_shows_and_not_when_it_doesnt() {
// An operator that isn't read (`Zq`) on a layer that shows: the layers would draw without it.
let shown = native(&layer("Art", true, &format!("{}\n0 0 0 1 0 0 Zq", square(10, 10))));
let r = import(&eps(&page_square(10, 10), &shown)).unwrap();
assert_eq!(names(&r.document), ["Layer 1"]);
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read") && w.contains("`Zq`")), "{:?}", r.warnings);
// On a hidden layer it only costs that layer's look.
let hidden = native(&(layer("Art", true, &square(10, 10)) + &layer("Old", false, "0 0 0 1 0 0 Zq")));
let r = import(&eps(&page_square(10, 10), &hidden)).unwrap();
assert_eq!(names(&r.document), ["Art", "Old"]);
assert!(r.warnings.iter().any(|w| w.contains("hidden layers have art this can't read")), "{:?}", r.warnings);
}
#[test]
fn text_and_its_strokes_come_from_the_page_into_the_layer_of_its_text_object() {
let page = format!(
"{}\n/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show\n10 50 moveto (Hi) false charpath 2 setlinewidth stroke",
page_square(10, 10)
);
let words = layer("Words", true, "/AI11Text :\n0 /FreeUndo ,\n;\n\n0 0 Xd");
let art = native(&(layer("Art", true, &square(10, 10)) + &words));
let r = import(&eps(&page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Words"], "{:?}", r.warnings);
assert_eq!(kinds(&r.document.layers[0]), ["path"]);
// The text and the path of its stroke, where the text object is (no group of their own).
assert_eq!(kinds(&r.document.layers[1]), ["text", "path"], "{:?}", r.warnings);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[test]
fn a_damaged_editing_copy_leaves_the_page() {
// Not ASCII85, ASCII85 of something that isn't Zstandard, Zstandard of something that isn't art.
let plain = |data: &str| {
let lines = format!("%{}", data);
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{lines}\n%AI9_PrivateDataEnd\n",
page_square(10, 10)
)
.into_bytes()
};
for bytes in [plain("this is not ascii85 {{{~>"), plain(&ascii85(b"not zstandard at all")), eps(&page_square(10, 10), "nothing of the art here")]
{
let r = import(&bytes).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
}
// A file that has the data but not in a form that can be read says so (and a write over it asks).
let r = import(&plain("this is not ascii85 {{{~>")).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("damaged") && crate::is_loss(w)), "{:?}", r.warnings);
}
/// The `.ai`'s editing data: its marker, then the Zstandard stream.
fn private(native: &str) -> Vec<u8> {
[b"%AI24_ZStandard_Data".as_slice(), &zstd(native)].concat()
}
/// A page of 100 × 100 with a square at (`x`, `y`) in one layer, as a PDF part would give.
fn pdf_part(x: f64, y: f64) -> Document {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let path = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, y, x + 4.0, y + 4.0));
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(0.0, 0.0, 1.0, 0.0));
let item = vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(paint));
let n = Node::path(id, path, vectorcraft_doc::Appearance { items: vec![item], ..Default::default() });
let layer = d.layers[0].id;
d.insert(Some(layer), 0, n).unwrap();
d
}
#[test]
fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
// A square on the artboard, and one above it (the artboard is y 0 to 100 here).
let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60))));
let (d, warnings) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()]);
assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}");
assert_eq!(warnings, ["a note"], "nothing else to say");
let above = d.layers[1].children().unwrap()[0].visual_bounds().unwrap();
assert!(above.y1 <= 0.0 + 1e-6, "the art above the artboard is kept, outside it: {above:?}");
assert!(!d.layers[2].visible);
}
#[test]
fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
let pdf = pdf_part(10.0, 86.0);
// No editing data this reads.
let (d, w) = crate::layered_ai(b"nothing", pdf.clone(), vec![]);
assert_eq!(d.layers.len(), pdf.layers.len());
assert!(w.is_empty());
// Editing data that isn't for this page.
let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
let (d, w) = crate::layered_ai(&private(&other), pdf.clone(), vec![]);
assert_eq!(names(&d), names(&pdf));
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
}
#[test]
fn a_gray_is_a_grayscale_colour() {
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour
// stays in its grayscale model.
let art = native(&layer("Art", true, "0.25 g\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf"));
let page = "0 0 0 0.75 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let fill = r.document.layers[0].children().unwrap()[0].appearance.fill().and_then(|f| f.paint.color());
assert_eq!(fill, Some(vectorcraft_color::Color::gray(0.75)));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
/// `native` as a CS-era file keeps it: zlib, under `%AI9_DataStream`.
fn older_eps(page: &str, native: &str) -> Vec<u8> {
use std::io::Write;
let mut z = flate2::write::ZlibEncoder::new(vec![], flate2::Compression::default());
z.write_all(native.as_bytes()).unwrap();
let stream = ascii85(&z.finish().unwrap());
let lines: Vec<String> = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI9_DataStream\n{}\n%AI9_PrivateDataEnd\n",
lines.join("\n")
)
.into_bytes()
}
#[test]
fn files_from_before_zstandard_give_their_layers_too() {
let art = native(&(layer("Back", true, &square(10, 10)) + &layer("Hidden", false, &square(50, 50))));
let r = import(&older_eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Back", "Hidden"], "{:?}", r.warnings);
assert_eq!([r.document.layers[0].visible, r.document.layers[1].visible], [true, false]);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[test]
fn custom_colours_open_paths_and_planar_group_marks_are_read() {
// `x` and `X` are the custom colours of a fill and a stroke (four inks, a name, a tint), `Xx` one
// with its Lab values; `H` ends an open path, `XP` marks a group the app made from a planar map.
let body = "0.1 0.2 0.3 0.4 (Spot) 0 x\n0 0 0 1 (Edge) 0 X\n10 10 m\n14 10 L\n14 14 L\n10 14 L\nf\n\
0.8 0.1 0 0 62.7 -28 -42 (Blue) 0 2 Xx\n(Adobe Planar Group) 1 0 0 XP\n20 20 m\n24 20 L\n24 24 L\nH\nF";
let r = import(&older_eps(
"0 0 0 0 setcmykcolor 10 10 moveto 14 10 lineto 14 14 lineto 10 14 lineto closepath fill",
&native(&layer("Art", true, body)),
))
.unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert_eq!(kinds(&r.document.layers[0]), ["path", "path"]);
}
/// A text document of `stories` (each as the text, its alignment code, its anchor and the frame's
/// translation, and the start of its first line from the anchor), one font and a 14 point default.
/// A story: its text, alignment code, anchor, frame translation and first line's start.
type Story<'a> = (&'a str, u8, (f64, f64), (f64, f64), f64);
fn text_document(stories: &[Story<'_>]) -> String {
let story = |(text, align, anchor, _t, start): &Story<'_>| {
let n = text.chars().count();
format!(
"<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 {align} >> /6 0 >> >> /1 {n} >> ] >> \
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 {n} >> ] >> >> \
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ {} {} ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
<< /99 /S /0 << /0 [ {start} 0.0 ] >> /15 << /0 {n} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>",
anchor.0, anchor.1
)
};
let frame =
|(_, _, _, t, _): &(&str, u8, (f64, f64), (f64, f64), f64)| format!("<< /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 {} {} ] >> >> >>", t.0, t.1);
format!(
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} ] >> >>\n/1 << /1 [ {} ] /2 << /1 12.0 >> >>\n",
stories.iter().map(frame).collect::<Vec<_>>().join(" "),
stories.iter().map(story).collect::<Vec<_>>().join(" ")
)
}
/// The editing copy of a 100 × 100 file with `layers` and the text `document`; its template box is
/// centred at (50, 50).
fn native_with_text(layers: &str, document: &str) -> String {
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
%AI5_NumLayers: 2\n{layers}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
lines.join("\n")
)
}
/// A text object of story `story`, as the layers hold it.
fn text_object(story: u32) -> String {
format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n2 /TextAntialiasing ,\n;\n")
}
fn text_of(l: &Node) -> Vec<&vectorcraft_doc::TextObject> {
l.children().into_iter().flatten().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(&**t) } else { None }).collect()
}
#[test]
fn type_on_a_hidden_layer_comes_from_the_text_document() {
// Canvas centre 8191.5 is the template box's centre (50, 50): the anchor is 8.5 right of it and
// 11.5 above it, so at (58.5, 61.5) in the art, which is 38.5 down the page.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Mid\r", 2, (8191.5, 8191.5), (0.0, 0.0), -10.0)]);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings);
let spare = &r.document.layers[1];
assert!(!spare.visible);
let texts = text_of(spare);
assert_eq!(texts.len(), 2, "{:?}", r.warnings);
let [a, b] = [texts[0], texts[1]];
assert_eq!(a.plain_text(), "Hi there");
let [.., e, f] = a.xf.as_coeffs();
assert!((e - 58.5).abs() < 1e-6 && (f - 38.5).abs() < 1e-6, "{:?}", a.xf);
assert_eq!(a.runs[0].style.size, 14.0);
assert_eq!(a.para.justify, vectorcraft_doc::Justify::Left);
// Centred type is anchored at its middle.
assert_eq!(b.para.justify, vectorcraft_doc::Justify::Center);
let [.., e, f] = b.xf.as_coeffs();
assert!((e - 50.0).abs() < 1e-6 && (f - 50.0).abs() < 1e-6, "{:?}", b.xf);
assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
}
#[test]
fn type_with_a_frame_it_cant_read_is_left_out_with_a_note() {
// A story whose frame says more than a matrix (a text area, say) isn't guessed at.
let mut doc = text_document(&[("Area\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
doc = doc.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << /2 [ 1 0 0 1 0 0 ] /7 1 >>");
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert!(text_of(&r.document.layers[1]).is_empty());
assert!(r.warnings.iter().any(|w| w.contains("1 text objects on hidden layers")), "{:?}", r.warnings);
assert!(crate::is_loss(r.warnings.iter().find(|w| w.contains("couldn't be read")).unwrap()));
}
#[test]
fn shown_type_keeps_the_pages_and_type_off_the_page_comes_from_the_text_document() {
// Story 0 is where the page draws "Hi there"; story 1 is off the page, so the page has nothing of it.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Away\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let texts = text_of(&r.document.layers[0]);
// The page's own object, and the other made from the file.
let all: Vec<String> = {
let mut v = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
v.push(t.plain_text());
}
});
v
};
assert_eq!(all.len(), 2, "{all:?} {:?}", r.warnings);
assert!(all.contains(&"Hi there".to_string()) && all.contains(&"Away".to_string()), "{all:?}");
assert_eq!(texts.len(), 2, "the page's type is in the layer as it is, not in a group");
assert!(!r.warnings.iter().any(|w| w.contains("order the page paints")), "{:?}", r.warnings);
}
#[test]
fn a_text_object_written_as_comments_is_read_like_any_other() {
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let commented: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect();
let art = native_with_text(&layer("Spare", false, &(square(10, 10) + "\n" + &commented)), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts = text_of(&r.document.layers[0]);
assert_eq!(texts.len(), 1, "{:?}", r.warnings);
assert_eq!(texts[0].plain_text(), "Hi");
}
#[test]
fn an_area_type_object_with_its_own_nested_art_doesnt_swallow_the_art_after_it() {
let area = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n3 /StoryIndex ,\n/Art :\n1 Ap\n0 0 m\n9 0 L\n9 9 L\nn\n; /ConfiningPath ,\n2 /TextAntialiasing ,\n;\n";
let art = native(&layer("Art", true, &(square(10, 10) + "\n" + area + &square(30, 30))));
let r = import(&eps(&(page_square(10, 10) + "\n" + &page_square(30, 30)), &art)).unwrap();
let paths = r.document.layers[0].children().map_or(0, Vec::len);
assert!(paths >= 2, "{paths} {:?}", r.warnings);
}
#[test]
fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() {
// The page sets "Hi there" as "Hi" and " there" (as when kerning changes); the story is one line.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi) show 70 61.5 moveto (there) show";
let r = import(&eps(page, &art)).unwrap();
// One text, with the line's space between the pieces.
let art = &r.document.layers[0];
assert_eq!(kinds(art), ["text"], "{:?}", r.warnings);
let texts: Vec<String> = text_of(art).iter().map(|t| t.plain_text()).collect();
assert_eq!(texts, ["Hi there"]);
// Its bounds are laid out afresh for the whole text, not those of the first piece.
let NodeKind::Text(t) = &art.children().unwrap()[0].kind else { panic!("text") };
assert!(t.cached_bounds.is_none() && t.cached_baselines.is_empty());
}
#[test]
fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings);
}
#[test]
fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() {
// The file writes some stories twice (plain, then as comments) and others only as comments.
let doc = text_document(&[("One\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Two\r", 0, (8300.0, 8180.0), (0.0, 0.0), 0.0)]);
let commented = |story| -> String { text_object(story).lines().map(|l| format!("%_{l}\n")).collect() };
let body = text_object(0) + &commented(0) + &commented(1);
let art = native_with_text(&layer("Spare", false, &body), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let texts: Vec<String> = text_of(&r.document.layers[0]).iter().map(|t| t.plain_text()).collect();
assert_eq!(texts, ["One", "Two"], "{:?}", r.warnings);
}
#[test]
fn a_shown_text_object_on_the_page_that_the_page_doesnt_draw_is_left_out() {
// Story 1 is at (58.5, 61.5) on the 100 × 100 page, where the page paints no type: the file
// keeps it but the app that wrote it doesn't draw it (type turned to outlines leaves one).
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Ghost\r", 0, (8200.0, 8200.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
assert_eq!(all, ["Hi there"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings);
}
#[test]
fn pieces_of_two_lines_the_page_draws_alternately_go_each_to_its_own_text() {
// "Hello" is drawn as "He" and "llo", with "abcd" ("ab", "cd") drawn between them.
let doc = text_document(&[("Hello\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("abcd\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (He) show 58.5 81.5 moveto (ab) show \
74.1 81.5 moveto (cd) show 76.4 61.5 moveto (llo) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
all.sort();
assert_eq!(all, ["Hello", "abcd"], "{:?}", r.warnings);
}
#[test]
fn a_run_longer_than_any_text_doesnt_overflow() {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]).replace("/1 9 >>", "/1 1e30 >>");
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings);
}
#[test]
fn more_text_objects_than_are_matched_leave_the_page() {
let many = text_object(0).repeat(5001);
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &many)), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("too many text objects") && crate::is_loss(w)), "{:?}", r.warnings);
}
#[test]
fn a_dictionary_left_open_at_the_end_of_a_layer_is_a_damaged_file() {
let open = "/AI11Text :\n0 /FreeUndo ,\n";
let art = native(&(layer("A", true, &(square(10, 10) + open)) + &layer("B", true, &square(20, 20))));
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| crate::is_loss(w)), "{:?}", r.warnings);
}
#[test]
fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
// Point type from the text document, shown or not; a story it can't read is left out, with a
// note that is a loss.
let mut doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Area\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]);
// Story 1 in frame 1, a frame that says more than a matrix.
let at = doc.rfind("/1 << /0 [ << /0 0 >> ]").unwrap();
doc.replace_range(at..at + 23, "/1 << /0 [ << /0 1 >> ]");
let at = doc.rfind("/2 << /2 [ 1 0 0 1 0 0 ] >>").unwrap();
doc.replace_range(at..at + 27, "/2 << /2 [ 1 0 0 1 0 0 ] /7 1 >>");
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Words", true, &(text_object(0) + &text_object(1)))), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
assert_eq!(names(&d), ["Back", "Words"], "{notes:?}");
let texts: Vec<String> = text_of(&d.layers[1]).iter().map(|t| t.plain_text()).collect();
assert_eq!(texts, ["Hi there"], "{notes:?}");
let left = notes.iter().find(|n| n.contains("left out")).unwrap();
assert!(left.starts_with("1 ") && crate::is_loss(left), "{notes:?}");
assert!(!notes.iter().any(|n| n.contains("order the page paints")), "{notes:?}");
assert!(crate::ai_alone(b"%AI24_ZStandard_Data nothing").is_err());
}
+27 -2
View File
@@ -18,14 +18,39 @@ use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err};
/// The font a program uses before it sets one.
const DEFAULT_FONT: &str = "Helvetica";
/// A word of a style as PostScript names abbreviate it (`AkzidenzGroteskPro-BoldCnIt`), in full.
fn style_word(w: &str) -> String {
match w {
"Lt" => "Light",
"Md" | "Med" => "Medium",
"Bd" => "Bold",
"XBd" => "Extra Bold",
"Blk" => "Black",
"Hv" => "Heavy",
"Th" => "Thin",
"Rg" => "Regular",
"Sb" | "Smbd" => "Semibold",
"Cn" | "Cond" => "Condensed",
"Ext" | "Ex" => "Extended",
"It" => "Italic",
"Obl" => "Oblique",
w => w,
}
.to_string()
}
/// A font's family and style from its PostScript name: `Helvetica-BoldOblique` → (`Helvetica`,
/// `Bold Oblique`), `TimesNewRomanPS-BoldMT` → (`Times New Roman`, `Bold`), without a subset
/// prefix (`ABCDEF+`).
/// prefix (`ABCDEF+`) or the `*1` of a re-encoded copy (`ABCDEF+RollerBabyBV*1`).
pub fn family_style(name: &str) -> (String, String) {
let name = match name.split_once('+') {
Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest,
_ => name,
};
let name = match name.rsplit_once('*') {
Some((base, n)) if !base.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => base,
_ => name,
};
let (family, style) = name.split_once('-').unwrap_or((name, ""));
let trim = |s: &str| -> String {
let s = ["PSMT", "MT", "PS"].iter().find_map(|suffix| s.strip_suffix(suffix)).unwrap_or(s);
@@ -43,7 +68,7 @@ pub fn family_style(name: &str) -> (String, String) {
};
let style = match trim(style).as_str() {
"" | "Roman" | "Regular" | "Book" | "Normal" => "Regular".to_string(),
s => s.to_string(),
s => s.split(' ').map(style_word).collect::<Vec<_>>().join(" "),
};
(trim(family), style)
}
+1 -1
View File
@@ -34,7 +34,7 @@ mod ps;
mod scene;
mod tiff;
pub use import::{Imported, family_style, import};
pub use import::{Imported, ai_alone, family_style, import, is_loss, layered_ai};
pub use print::{PrintJob, PrintPage, print};
use vectorcraft_doc::Document;
+2 -1
View File
@@ -19,8 +19,9 @@ vectorcraft-doc = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
image = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
socket2 = { workspace = true }
+2
View File
@@ -35,6 +35,8 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_conventions;
#[cfg(test)]
mod tests_distortkeys;
#[cfg(test)]
mod tests_exportas;
+3 -2
View File
@@ -294,9 +294,10 @@ with the {effect} effect.
Method:
1. open_file the image: it becomes a document of its own pixel size.
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale or
2. imageTrace.make with the preset name, adjusting `params` — mode blackAndWhite, grayscale,
color (palette limited, fullTone for photos, automatic, or documentLibrary to trace with
the document's swatches or a swatch `library`), then threshold, colors (colorDetail for
the document's swatches or a swatch `library`) or logo (flat-colour logos only; the Flat Logo
preset; colours via logoColors), then threshold, colors (colorDetail for
fullTone and automatic), paths, corners, noise, method abutting or overlapping,
ignoreWhite, snapCurvesToLines; for line art, strokes: true (with strokeWidth, the widest
line in pixels) traces lines as stroked centre lines instead of filled outlines. Run
+8 -1
View File
@@ -16,6 +16,9 @@ pub const DOC_URI: &str = "vectorcraft://document";
/// The whole document model as JSON.
pub const DOC_JSON_URI: &str = "vectorcraft://document/json";
/// The live command catalog, including enabled state.
pub const COMMANDS_URI: &str = "vectorcraft://commands";
/// Why a resource could not be read. The distinction matters: the server turns it into `-32002`
/// (no such resource) or `-32602` (bad URI), and guessing from the message text would not hold.
#[derive(Debug)]
@@ -79,6 +82,7 @@ pub fn list() -> Value {
json!({"resources": [
{"uri": DOC_URI, "name": "document", "title": "Active document (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"},
{"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "mimeType": "application/json"},
{"uri": COMMANDS_URI, "name": "commands", "title": "Command catalog", "description": "Command ids, labels, parameter descriptions and current enabled state", "mimeType": "application/json"},
]})
}
@@ -104,6 +108,9 @@ pub fn template_source(uri: &str) -> (&str, Option<Live>) {
/// `resources/read`: the document behind a resource URI.
pub fn read(b: &mut dyn Backend, uri: &str) -> Result<Value, ReadError> {
if uri == COMMANDS_URI {
return b.call("engine.commands", json!({})).map_err(ReadError::Backend);
}
if uri == DOC_URI {
return b.call("document.inspect", json!({})).map_err(ReadError::Backend);
}
@@ -224,7 +231,7 @@ mod tests {
#[test]
fn lists_are_well_formed() {
let l = list();
assert_eq!(l["resources"].as_array().map(Vec::len), Some(2));
assert_eq!(l["resources"].as_array().map(Vec::len), Some(3));
let t = templates();
let items = t["resourceTemplates"].as_array().expect("templates");
assert_eq!(items.len(), TEMPLATES.len());
+34 -5
View File
@@ -11,7 +11,8 @@ use crate::tools::{call_tool, tool_definitions};
/// The MCP revision we implement.
pub const PROTOCOL_VERSION: &str = "2025-06-18";
const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
const MODERN_VERSION: &str = "2026-07-28";
const SUPPORTED_VERSIONS: &[&str] = &[MODERN_VERSION, PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
@@ -22,8 +23,8 @@ const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "VectorCraft is a professional vector illustration app. Coordinates are \
points in document space (y down, origin at the first artboard's top-left; a new document is 612×792). \
Draw with draw_shape / draw_path, change colours with set_paint, look with screenshot and inspect_document. \
Every menu action is a command: find it with list_commands and run it with run_command. New objects become \
Draw with draw_shape / draw_path, change colours with set_paint, look with render_preview and doc_inspect. \
Every menu action is a command: find it with command_list and run it with command_run (or command_batch for several). New objects become \
the selection, and most commands act on the selection (or on explicit `ids`). prompts/list has ready-made \
workflows, completion/complete finishes a command id, a format, an effect or a swatch name, and \
vectorcraft://object/{id}, vectorcraft://command/{id}, vectorcraft://effect/{id} and \
@@ -33,6 +34,7 @@ vectorcraft://swatch/{name} read one thing at a time instead of the whole docume
pub struct Server {
backend: Box<dyn Backend>,
initialized: bool,
modern: bool,
/// Whether this server's client asked for log records (`logging/setLevel`). The queue is
/// process-wide, so a server whose client didn't ask leaves it to the one that did.
logging: bool,
@@ -48,7 +50,7 @@ fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false, logging: false }
Self { backend, initialized: false, modern: false, logging: false }
}
pub fn backend(&mut self) -> &mut dyn Backend {
@@ -136,7 +138,26 @@ impl Server {
let r = vectorcraft_engine::guard::catch_panic(|| self.request(method, &params))
.unwrap_or_else(|msg| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {msg} (please report this bug)"))));
Some(match r {
Ok(r) => response(id, r),
Ok(mut r) => {
let modern = params
.get("_meta")
.and_then(|m| m.get("io.modelcontextprotocol/protocolVersion"))
.and_then(Value::as_str)
.map_or(self.modern, |v| v == MODERN_VERSION);
if modern && let Some(result) = r.as_object_mut() {
let ttl = match method {
"tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000),
"resources/read" | "prompts/get" => Some(0),
_ => None,
};
if let Some(ttl) = ttl {
result.insert("resultType".into(), json!("complete"));
result.insert("ttlMs".into(), json!(ttl));
result.insert("cacheScope".into(), json!("private"));
}
}
response(id, r)
}
Err((code, m)) => error(id, code, m),
})
}
@@ -154,6 +175,7 @@ impl Server {
"initialize" => {
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
self.modern = version == MODERN_VERSION;
Ok(json!({
"protocolVersion": version,
"capabilities": {
@@ -172,6 +194,13 @@ impl Server {
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
if let Some(arguments) = args.as_object()
&& let Some(def) = tool_definitions().into_iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name))
&& let Some(properties) = def.get("inputSchema").and_then(|s| s.get("properties")).and_then(Value::as_object)
&& let Some(key) = arguments.keys().find(|k| !properties.contains_key(*k))
{
return Err((INVALID_PARAMS, format!("unknown argument `{key}` for `{name}`")));
}
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
}
"resources/list" => Ok(resources::list()),
+1 -1
View File
@@ -223,7 +223,7 @@ fn headless_end_to_end() {
// Resources.
let v = rpc(&mut s, 10, "resources/list", json!({}));
assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 2);
assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 3);
let v = rpc(&mut s, 11, "resources/read", json!({"uri": "vectorcraft://document"}));
let text = v["result"]["contents"][0]["text"].as_str().unwrap();
assert!(serde_json::from_str::<Value>(text).unwrap()["layers"].is_array());
+131
View File
@@ -0,0 +1,131 @@
use crate::{Backend, Headless, Server, tool_definitions};
use serde_json::{Value, json};
fn rpc(s: &mut Server, method: &str, params: Value) -> Value {
s.handle(json!({"jsonrpc":"2.0","id":1,"method":method,"params":params})).unwrap()
}
fn call(s: &mut Server, name: &str, arguments: Value) -> Value {
let r = rpc(s, "tools/call", json!({"name":name,"arguments":arguments}));
assert!(r.get("error").is_none(), "{r}");
r["result"].clone()
}
fn payload(r: &Value) -> Value {
serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap()
}
#[test]
fn conventions_catalog_and_strict_keys() {
let mut s = Server::new(Box::new(Headless::with_document()));
let tools = tool_definitions();
for name in [
"command_list",
"command_run",
"command_batch",
"doc_inspect",
"render_preview",
"ui_inspect",
"ui_screenshot",
"list_commands",
"run_command",
] {
assert!(tools.iter().any(|t| t["name"] == name), "missing {name}");
}
for t in tools {
for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][hint].is_boolean(), "{t}");
}
let r = rpc(&mut s, "tools/call", json!({"name":t["name"],"arguments":{"typo":1}}));
assert_eq!(r["error"]["code"], -32602, "{r}");
}
assert_eq!(rpc(&mut s, "ping", json!({}))["result"], json!({}));
}
#[test]
fn conventions_commands_batch_and_bounded_preview() {
let mut s = Server::new(Box::new(Headless::with_document()));
let list = call(&mut s, "command_list", json!({"filter":"shape.rectangle","enabled_only":true}));
assert_eq!(list["isError"], false, "{list}");
assert!(payload(&list).as_array().unwrap().iter().any(|c| c["id"] == "shape.rectangle"));
let steps = json!([{"id":"shape.rectangle","params":{"x":10,"y":20,"width":30,"height":40}}, {"id":"no.such.command"}, {"id":"shape.rectangle","params":{"x":0,"y":0,"width":20,"height":20}}]);
for (stop, total) in [(true, 2), (false, 3)] {
let r = call(&mut s, "command_batch", json!({"steps":steps,"stop_on_error":stop}));
assert_eq!(r["isError"], true, "{r}");
let p = payload(&r);
assert_eq!(p["results"].as_array().unwrap().len(), total);
assert_eq!(p["failed"], 1);
assert_eq!(p["completed"], total - 1);
}
let before = call(&mut s, "doc_inspect", json!({}));
let r = call(&mut s, "render_preview", json!({"max_side":64}));
assert_eq!(r["isError"], false, "{r}");
let png = vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert!(img.width() <= 64 && img.height() <= 64);
assert_eq!(before, call(&mut s, "doc_inspect", json!({})));
for max in [json!(0), json!(4097), json!("64")] {
assert_eq!(call(&mut s, "render_preview", json!({"max_side":max}))["isError"], true);
}
for name in ["ui_inspect", "ui_screenshot"] {
assert_eq!(call(&mut s, name, json!({}))["isError"], true);
}
}
struct Panics;
impl Backend for Panics {
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
panic!("backend bug")
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"test".into()
}
}
#[test]
fn conventions_tool_panics_are_tool_errors() {
let mut s = Server::new(Box::new(Panics));
let r = call(&mut s, "list_commands", json!({}));
assert_eq!(r["isError"], true);
assert!(r["content"][0]["text"].as_str().unwrap().contains("backend bug"));
assert_eq!(rpc(&mut s, "ping", json!({}))["result"], json!({}));
}
#[test]
fn conventions_resources_and_versioned_results() {
let mut s = Server::new(Box::new(Headless::with_document()));
let modern = json!({"io.modelcontextprotocol/protocolVersion":"2026-07-28"});
for (method, extra, ttl) in [
("tools/list", json!({}), 600_000),
("resources/list", json!({}), 600_000),
("resources/templates/list", json!({}), 600_000),
("prompts/list", json!({}), 600_000),
("resources/read", json!({"uri":"vectorcraft://document"}), 0),
("resources/read", json!({"uri":"vectorcraft://commands"}), 0),
] {
let legacy = rpc(&mut s, method, extra.clone());
assert!(legacy["result"].is_object(), "{legacy}");
assert!(legacy["result"].get("resultType").is_none());
let mut params = extra.clone();
params["_meta"] = modern.clone();
let r = rpc(&mut s, method, params);
assert_eq!(r["result"]["resultType"], "complete", "{r}");
assert_eq!(r["result"]["ttlMs"], ttl);
assert_eq!(r["result"]["cacheScope"], "private");
let mut stripped = r["result"].clone();
for key in ["resultType", "ttlMs", "cacheScope"] {
stripped.as_object_mut().unwrap().remove(key);
}
assert_eq!(stripped, legacy["result"]);
assert!(rpc(&mut s, method, extra)["result"].get("resultType").is_none());
}
let r = rpc(&mut s, "resources/list", json!({}));
assert!(r["result"]["resources"].as_array().unwrap().iter().any(|r| r["uri"] == "vectorcraft://commands"));
let commands = rpc(&mut s, "resources/read", json!({"uri":"vectorcraft://commands"}));
let commands: Value = serde_json::from_str(commands["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(commands, payload(&call(&mut s, "command_list", json!({}))));
assert_eq!(rpc(&mut s, "initialize", json!({"protocolVersion":"2026-07-28"}))["result"]["protocolVersion"], "2026-07-28");
assert_eq!(rpc(&mut s, "tools/list", json!({}))["result"]["resultType"], "complete");
rpc(&mut s, "initialize", json!({"protocolVersion":"2025-06-18"}));
assert!(rpc(&mut s, "tools/list", json!({}))["result"].get("resultType").is_none());
}
+138 -9
View File
@@ -51,7 +51,7 @@ fn mods_schema() -> Value {
})
}
fn obj(props: Value, required: &[&str]) -> Value {
let mut o = json!({"type": "object", "properties": props});
let mut o = json!({"type": "object", "properties": props, "additionalProperties": false});
if !required.is_empty() {
o["required"] = json!(required);
}
@@ -64,12 +64,14 @@ fn save_extensions() -> Vec<&'static str> {
}
fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value {
let writes_file = matches!(name, "screenshot" | "save_file" | "export");
let read_only = read_only && !writes_file;
json!({
"name": name,
"title": title,
"description": desc,
"inputSchema": schema,
"annotations": {"title": title, "readOnlyHint": read_only, "openWorldHint": false},
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only, "idempotentHint": read_only, "openWorldHint": false},
})
}
@@ -77,6 +79,52 @@ fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) ->
pub fn tool_definitions() -> Vec<Value> {
let empty = || obj(json!({}), &[]);
vec![
tool(
"command_list",
"List commands",
"Discover command ids, labels, parameter descriptions and enabled state. Returns an array.",
obj(json!({"filter": string("Case-insensitive id, label or menu substring"), "enabled_only": {"type":"boolean"}}), &[]),
true,
),
tool(
"command_run",
"Run a command",
"Run an engine command by id with its JSON params. Command parameters follow the registry's descriptions.",
obj(json!({"id": string("Command id"), "params": {"type":"object"}}), &["id"]),
false,
),
tool(
"command_batch",
"Run several commands",
"Run steps in order; each edit is its own undo step. Returns completed, failed and results. Stops on the first error by default.",
obj(
json!({"steps": {"type":"array", "items":obj(json!({"id":string("Command id"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}}),
&["steps"],
),
false,
),
tool(
"doc_inspect",
"Inspect document",
"Summary of the active document, including artboards, layer tree, selection and history.",
obj(json!({"depth":{"type":"integer", "minimum":0},"childLimit":{"type":"integer", "minimum":0}}), &[]),
true,
),
tool(
"render_preview",
"Render a preview",
"Render the first artboard as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
obj(json!({"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[]),
true,
),
tool("ui_inspect", "Inspect the interface", "Inspect the connected desktop interface; returns a tool error in headless mode.", empty(), true),
tool(
"ui_screenshot",
"Capture the interface",
"Capture the connected desktop window as inline PNG; requires a running desktop app.",
empty(),
true,
),
tool(
"list_commands",
"List commands",
@@ -674,13 +722,24 @@ fn filter_commands(all: Value, a: &Args) -> Value {
fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, String> {
let j = |v: Value| Ok(ToolResult::json(&v));
match name {
"command_list" => {
let all = b.call("engine.commands", json!({}))?;
let mut filters = a.clone();
if let Some(v) = filters.remove("enabled_only") {
filters.insert("enabledOnly".into(), v);
}
j(filter_commands(all, &filters))
}
"command_batch" => command_batch(b, a),
"render_preview" => render_preview(b, a),
"ui_screenshot" => screenshot(b, &Map::from_iter([("window".into(), json!(true))])),
"list_commands" => {
let all = b.call("engine.commands", json!({}))?;
let list = filter_commands(all, a);
j(json!({"count": list.as_array().map_or(0, Vec::len), "commands": list}))
}
"run_command" | "invoke_menu" => {
let cmd = req_str(a, "command")?;
"command_run" | "run_command" | "invoke_menu" => {
let cmd = req_str(a, if name == "command_run" { "id" } else { "command" })?;
let params = match a.get("params") {
None | Some(Value::Null) => json!({}),
Some(v @ Value::Object(_)) => v.clone(),
@@ -689,13 +748,13 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
let method = if name == "invoke_menu" && b.has_ui() { "ui.menu.invoke" } else { "engine.execute" };
j(b.call(method, json!({"command": cmd, "params": params}))?)
}
"inspect_document" => {
"doc_inspect" | "inspect_document" => {
// Only the slicing options reach the command, which validates them.
let slice: Map<String, Value> =
a.iter().filter(|(k, _)| matches!(k.as_str(), "depth" | "childLimit")).map(|(k, v)| (k.clone(), v.clone())).collect();
j(b.call("document.inspect", Value::Object(slice))?)
}
"inspect_ui" => {
"ui_inspect" | "inspect_ui" => {
need_ui(b, name)?;
j(b.call("ui.inspect", json!({}))?)
}
@@ -826,8 +885,78 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
Value::Null => &empty,
_ => return ToolResult::error("tool arguments must be a JSON object"),
};
match dispatch(b, name, a) {
Ok(r) => r,
Err(e) => ToolResult::error(format!("{name}: {e}")),
match vectorcraft_engine::guard::catch_panic(|| dispatch(b, name, a)) {
Ok(Ok(r)) => r,
Ok(Err(e)) => ToolResult::error(format!("{name}: {e}")),
Err(e) => ToolResult::error(format!("internal error in `{name}`: {e} (please report this bug)")),
}
}
fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let steps = a.get("steps").and_then(Value::as_array).ok_or("`steps` must be an array")?;
let stop = match a.get("stop_on_error") {
None => true,
Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?,
};
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
for step in steps {
let result = vectorcraft_engine::guard::catch_panic(|| {
let args = step.as_object().ok_or("each step must be an object")?;
if args.keys().any(|k| k != "id" && k != "params") {
return Err("unknown step argument (expected id, params)".into());
}
let id = req_str(args, "id")?;
let params = match args.get("params") {
None | Some(Value::Null) => json!({}),
Some(v @ Value::Object(_)) => v.clone(),
Some(_) => return Err("`params` must be an object".into()),
};
b.call("engine.execute", json!({"command":id,"params":params}))
})
.unwrap_or_else(|e| Err(format!("internal error: {e}")));
match result {
Ok(v) => {
completed += 1;
results.push(json!({"ok":true,"result":v}));
}
Err(e) => {
failed += 1;
results.push(json!({"ok":false,"error":e}));
if stop {
break;
}
}
}
}
let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results}));
r.is_error = failed > 0;
Ok(r)
}
fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let max = match a.get("max_side") {
None => 1024,
Some(v) => v.as_u64().filter(|v| (1..=4096).contains(v)).ok_or("`max_side` must be an integer in 1..4096")?,
};
let doc = b.call("document.inspect", json!({"depth":0}))?;
let board = doc.get("artboards").and_then(Value::as_array).and_then(|v| v.first()).ok_or("no such artboard")?;
let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?;
let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?;
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
return Err("invalid artboard dimensions".into());
}
// The headless renderer clamps scale at 0.01. Bound its allocation even for
// a huge artboard, then downsize to the requested dimensions.
let scale = (max as f64 / width.max(height)).clamp(0.01, 1.0);
if width.max(height) * scale > 4096.0 {
return Err("artboard is too large for a bounded preview".into());
}
let r = b.call("ui.render", json!({"scale":scale}))?;
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
let img = img.thumbnail(max as u32, max as u32);
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?;
Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()})))
}
+4 -2
View File
@@ -270,10 +270,12 @@ impl Writer {
// The drawing space the page shows (the marks' area).
let shown = sheet.view.inverse().transform_rect_bbox(paper);
let knockout = std::mem::replace(&mut ex.knockout, false);
// Marks keep their inks: they aren't converted to the destination.
// Marks keep their inks: they aren't converted to the destination. The page
// information is drawn as glyph shapes, like the other marks, not as text.
let convert = std::mem::replace(&mut ex.convert, false);
let outline = std::mem::replace(&mut ex.outline_text, true);
ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true);
(ex.knockout, ex.convert) = (knockout, convert);
(ex.knockout, ex.convert, ex.outline_text) = (knockout, convert, outline);
}
if window.is_some() {
s.pop();
+52 -4
View File
@@ -132,7 +132,7 @@ fn group_layer(
/// The note on a file carrying an editor's private data (#472): its PDF part, the one read, holds
/// only the art on its artboards (art on the pasteboard is in the private data alone).
pub(crate) const OFF_ARTBOARD_NOTE: &str =
pub const OFF_ARTBOARD_NOTE: &str =
"only the PDF-compatible part of this file was read: art outside its artboards is kept in the editor's private data alone, so it doesn't open";
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
@@ -387,6 +387,33 @@ impl CidText {
}
}
/// A ToUnicode value that names no character: U+FFFD alone (#708).
fn replacement(u: &hayro_interpret::hayro_cmap::BfString) -> bool {
use hayro_interpret::hayro_cmap::BfString;
match u {
BfString::Char(c) => *c == char::REPLACEMENT_CHARACTER,
BfString::String(s) => !s.is_empty() && s.chars().all(|c| c == char::REPLACEMENT_CHARACTER),
}
}
/// The character of each glyph of an embedded TrueType or OpenType font program, from its own
/// `cmap` (the first character mapped to each glyph); `None` for a bare CFF or a font without one.
pub(crate) fn font_chars(data: &[u8]) -> Option<HashMap<u32, char>> {
use skrifa::MetadataProvider;
// As for CidText: whole fonts are some megabytes.
if data.len() > 64 << 20 {
return None;
}
let font = skrifa::FontRef::new(data).ok()?;
let mut chars = HashMap::new();
for (c, g) in font.charmap().mappings() {
if let Some(c) = char::from_u32(c).filter(|c| !c.is_control()) {
chars.entry(g.to_u32()).or_insert(c);
}
}
(!chars.is_empty()).then_some(chars)
}
/// The ideographs of the Kangxi radicals U+2F00–U+2FD5 (their NFKC forms): the CID → Unicode
/// tables of some PDFs map 龍 to the radical ⿓, which looks the same but isn't the character.
const KANGXI: &str = "一丨丶丿乙亅二亠人儿入八冂冖冫几凵刀力勹匕匚匸十卜卩厂厶又口囗土士夂夊夕大女子宀寸小尢尸屮山巛工己巾干幺广廴廾弋弓彐彡彳心戈戶手支攴文斗斤方无日曰月木欠止歹殳毋比毛氏气水火爪父爻爿片牙牛犬玄玉瓜瓦甘生用田疋疒癶白皮皿目矛矢石示禸禾穴立竹米糸缶网羊羽老而耒耳聿肉臣自至臼舌舛舟艮色艸虍虫血行衣襾見角言谷豆豕豸貝赤走足身車辛辰辵邑酉釆里金長門阜隶隹雨靑非面革韋韭音頁風飛食首香馬骨高髟鬥鬯鬲鬼魚鳥鹵鹿麥麻黃黍黑黹黽鼎鼓鼠鼻齊齒龍龜龠";
@@ -603,6 +630,9 @@ struct Builder<'p> {
/// Font (cache key) → its characters by glyph, for CID-keyed fonts embedded without a
/// ToUnicode map.
cid_text: HashMap<u128, Option<Arc<CidText>>>,
/// Font (cache key) → the character of each glyph its embedded font program maps
/// ([`font_chars`]), for glyphs the file's ToUnicode map doesn't name (#708).
font_chars: HashMap<u128, Option<Arc<HashMap<u32, char>>>>,
/// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph
/// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines.
matched: HashMap<u128, Option<Arc<vectorcraft_text::FontFace>>>,
@@ -752,6 +782,7 @@ impl<'p> Builder<'p> {
families: None,
missing_fonts: vec![],
cid_text: HashMap::new(),
font_chars: HashMap::new(),
matched: HashMap::new(),
mask: None,
masked: vec![],
@@ -1267,17 +1298,34 @@ impl<'p> Builder<'p> {
return false;
}
let key = o.font_cache_key();
let (unicode, from_cid) = match o.as_unicode() {
let glyph = o.glyph_id().to_u32();
let (unicode, from_cid) = match o.as_unicode().filter(|u| !replacement(u)) {
Some(u) => (Some(u), false),
// No mapping, or one to U+FFFD (#708): the font program may still name the character.
None => {
let cid = self.cid_text.entry(key).or_insert_with(|| o.font_data().and_then(|d| CidText::of(d.data.as_ref().as_ref())).map(Arc::new));
(cid.as_ref().and_then(|c| c.unicode(o.glyph_id().to_u32())), true)
match cid.as_ref().and_then(|c| c.unicode(glyph)) {
Some(u) => (Some(u), true),
None => {
let chars = self
.font_chars
.entry(key)
.or_insert_with(|| o.font_data().and_then(|d| font_chars(d.data.as_ref().as_ref())).map(Arc::new));
(chars.as_ref().and_then(|m| m.get(&glyph)).map(|c| BfString::Char(*c)), false)
}
}
}
};
let text: String = match unicode {
Some(BfString::Char(c)) => c.to_string(),
Some(BfString::String(s)) => s,
None => return false,
None => {
if o.as_unicode().is_some_and(|u| replacement(&u)) {
let name = self.font_name(key, o).family;
self.warn(&format!("text in {name} whose characters the file doesn't name (U+FFFD) was kept as outlines"));
}
return false;
}
};
// Adobe's CID tables map some ideographs to their look-alike Kangxi radicals.
let text: String = if from_cid { text.chars().map(unify_radical).collect() } else { text };
+19 -4
View File
@@ -24,6 +24,9 @@ pub(crate) struct Placement {
pub size: f64,
/// Width of the em ÷ its height, in percent.
pub h_scale: f64,
/// How far the em leans along the baseline per unit of its height (a text matrix with a
/// shear: faux italic, or type slanted as a whole); 0 when it stands square on the baseline.
pub slant: f64,
}
impl Placement {
@@ -33,14 +36,23 @@ impl Placement {
let origin = m * Point::ORIGIN;
let ex = m * Point::new(1000.0, 0.0) - origin;
let ey = m * Point::new(0.0, 1000.0) - origin;
let (w, size) = (ex.hypot(), ey.hypot());
let w = ex.hypot();
// Upright in y-down document space: x to the right of up.
let upright = ex.cross(ey) < 0.0;
(origin.is_finite() && w.is_finite() && size.is_finite() && w > 0.01 && size > 0.01 && size < 1e5 && upright).then(|| Self {
if !(w.is_finite() && w > 0.01) {
return None;
}
let dir = ex / w;
// The em's height is across the baseline; what's left of `ey` along it is a slant.
let up = Vec2::new(dir.y, -dir.x);
let size = ey.dot(up);
let slant = ey.dot(dir) / size;
(origin.is_finite() && size.is_finite() && slant.is_finite() && size > 0.01 && size < 1e5 && upright).then(|| Self {
origin,
dir: ex / w,
dir,
size,
h_scale: w / size * 100.0,
slant,
})
}
@@ -211,6 +223,7 @@ impl TextLine {
let gap = (at.origin - self.next).dot(self.last_dir);
let on_line = opacity == self.opacity
&& at.dir.dot(self.at.dir) > 0.9995
&& (at.slant - self.at.slant).abs() < 1e-3
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
&& gap > -size * 0.3
&& gap < size * 3.0;
@@ -218,6 +231,7 @@ impl TextLine {
// that one ends.
let on_curve = !on_line
&& opacity == self.opacity
&& (at.slant - self.at.slant).abs() < 1e-3
&& at.dir.dot(self.last_dir) > CURVE_TURN_COS
&& at.dir.dot(self.last_dir) < 0.99999
&& (at.origin - self.next).hypot() < size * 0.6;
@@ -309,7 +323,8 @@ impl TextLine {
to_logical(&mut t);
}
let angle = self.at.dir.atan2();
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle);
// The glyphs' tops lean along the baseline (+x), up being -y in the type's own space.
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle) * Affine::skew(-self.at.slant, 0.0);
let db = vectorcraft_text::FontDb::global();
if let Some(path) = path {
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
+2 -2
View File
@@ -69,8 +69,8 @@ mod syntax;
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
pub use encrypt::Encryption;
pub use export::{export, export_with_report, page_areas};
pub use import::{import, import_with_report};
pub use pages::{PageInfo, PdfInfo, info, is_postscript};
pub use import::{OFF_ARTBOARD_NOTE, import, import_with_report};
pub use pages::{PageInfo, PdfInfo, illustrator_data, info, is_postscript};
pub use post::{THUMBNAIL_SIZE, Thumbnail};
pub use presets::*;
pub use print::*;
+44 -1
View File
@@ -1,7 +1,7 @@
//! The pages of a PDF to import: opening with a password, the pages picked, the box each page
//! is cropped to ([`CropTo`]) and [`info`].
use hayro_syntax::object::Dict;
use hayro_syntax::object::{Dict, Stream};
use hayro_syntax::page::Page;
use hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
use kurbo::{Affine, Rect};
@@ -128,6 +128,49 @@ pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) {
(init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) })
}
/// The `PieceInfo` key such files keep their private data under.
const PRIVATE_DATA_OWNER: &[u8] = b"Illustrator"; // brand-ok: the key the files use
/// Most bytes of an editor's private data read.
const MAX_PRIVATE: usize = 256 << 20;
/// The private data of an Illustrator `.ai` (its first page's `PieceInfo`): the `AIPrivateData`
/// streams in order, joined. `None` for a PDF without them (or with a stream that can't be read).
/// What it holds is the editor's own copy of the art (see `vectorcraft_eps::layered_ai`).
pub fn illustrator_data(bytes: &[u8], password: Option<&str>) -> Option<Vec<u8>> {
let pdf = open(bytes, password).ok()?;
let page = pdf.pages().first()?;
let private = page.raw().get::<Dict<'_>>(b"PieceInfo")?.get::<Dict<'_>>(PRIVATE_DATA_OWNER)?.get::<Dict<'_>>(b"Private")?;
let mut data = vec![];
for n in 1..=100_000u32 {
let Some(stream) = private.get::<Stream<'_>>(format!("AIPrivateData{n}").as_bytes()) else { break };
data.extend_from_slice(&inflated(&stream, MAX_PRIVATE - data.len())?);
}
(!data.is_empty()).then_some(data)
}
/// `stream` decoded, up to `most` bytes: the decoder is run here rather than by the PDF reader's
/// `decoded()`, which has no limit, so a stream of zeros can't grow to the size of the memory. Only a
/// stream that is plain or only deflated is read (the private data is no other); else, or when it
/// is larger once decoded, `None`.
fn inflated(stream: &Stream<'_>, most: usize) -> Option<Vec<u8>> {
use std::io::Read;
if stream.dict().get::<Dict<'_>>(b"DecodeParms").is_some() {
return None;
}
let raw = stream.raw_data();
let mut out = vec![];
let limit = u64::try_from(most).ok()?.checked_add(1)?;
match stream.filters().len() {
0 => out.extend_from_slice(&raw),
1 => {
flate2::read::ZlibDecoder::new(&*raw).take(limit).read_to_end(&mut out).ok()?;
}
_ => return None,
}
(out.len() <= most).then_some(out)
}
/// Does `page` carry an editor's private data (`PieceInfo` keys starting with `AIPrivateData`)?
/// A file saved without PDF compatibility keeps its art there and shows a placeholder page.
pub(crate) fn has_private_data(page: &Page<'_>) -> bool {
+1 -1
View File
@@ -373,7 +373,7 @@ pub struct AdvancedSettings {
impl Default for AdvancedSettings {
fn default() -> Self {
Self { font_subset_percent: 100.0, outline_text: true, overprint: Overprint::Preserve }
Self { font_subset_percent: 100.0, outline_text: false, overprint: Overprint::Preserve }
}
}
+6 -2
View File
@@ -342,9 +342,13 @@ fn text_exported_as_outlines() {
let mut d = doc(300.0, 100.0);
let t = TextObject::point(Point::new(10.0, 50.0), "Hello", CharStyle { size: 24.0, ..CharStyle::default() });
add(&mut d, Node::new(NodeId(0), NodeKind::Text(Box::new(t))));
let s = uncompressed(&d);
let mut outlined = PdfOptions::uncompressed();
outlined.settings.advanced.outline_text = true;
let s = String::from_utf8_lossy(&export(&d, &outlined).unwrap()).into_owned();
assert!(!s.contains("/Font"), "no fonts when outlining text");
let r = import_with_report(&export(&d, &PdfOptions::default()).unwrap(), &ImportOptions::default()).unwrap();
// Real text in an embedded font by default, as a PDF from Illustrator has it (#733).
assert!(uncompressed(&d).contains("/FontFile"), "a font embedded by default");
let r = import_with_report(&export(&d, &outlined).unwrap(), &ImportOptions::default()).unwrap();
let l = leaves(&r.document);
assert!(!l.is_empty());
assert!(l.iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
+23
View File
@@ -366,3 +366,26 @@ fn art_past_the_scan_limit_of_shown_layers_stays_shown() {
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.0, 1.0, 0.0)]);
assert!(r.warnings.iter().any(|w| w.contains("went to its page's layer")), "{:?}", r.warnings);
}
/// The editor's private data of a `.ai` is its `AIPrivateData` streams, joined in order.
#[test]
fn an_ai_files_private_data_is_its_streams_joined() {
let (a, b) = (first_extra(1), first_extra(1) + 1);
let page = |ai: bool| PdfPage {
entries: if ai {
format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {a} 0 R /AIPrivateData2 {b} 0 R /NumBlock 2 >> >> >>")
} else {
String::new()
},
..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f")
};
let (one, two) = (stream("", "%AI24_ZStandard_Data first "), stream("", "second"));
let ai = pdf_with_catalog(&[page(true)], &[&one, &two], "", None);
assert_eq!(illustrator_data(&ai, None).unwrap(), b"%AI24_ZStandard_Data first second");
// A PDF without them has none; neither has bytes that aren't a PDF.
assert!(illustrator_data(&pdf_with_catalog(&[page(false)], &[&one, &two], "", None), None).is_none());
assert!(illustrator_data(b"not a pdf", None).is_none());
// Only a plain or a deflated stream is read: a chain of filters is refused, not run unbounded.
let chained = stream("/Filter [/ASCIIHexDecode /ASCIIHexDecode]", "61>");
assert!(illustrator_data(&pdf_with_catalog(&[page(true)], &[&chained, &two], "", None), None).is_none());
}
+4 -2
View File
@@ -331,9 +331,11 @@ fn pdf_a_overprint_mode_is_0() {
#[test]
fn overprinting_art_imports_as_it_was_drawn() {
let d = overprinting(RED);
let plain = leaves(&import(&export(&d, json!({"advanced": {"overprint": "discard"}})).bytes).unwrap());
// What is drawn (a form's bounds may come back as an unpainted clipping path).
let painted = |bytes: &[u8]| leaves(&import(bytes).unwrap()).into_iter().filter(|l| l.2.is_some()).collect::<Vec<_>>();
let plain = painted(&export(&d, json!({"advanced": {"overprint": "discard"}})).bytes);
for v in [json!({}), json!({"createLayers": true})] {
assert_eq!(leaves(&import(&export(&d, v.clone()).bytes).unwrap()), plain, "{v}: the marks draw nothing");
assert_eq!(painted(&export(&d, v.clone()).bytes), plain, "{v}: the marks draw nothing");
}
}
+2 -2
View File
@@ -265,7 +265,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
stroke: None,
};
let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0 };
let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
let mut line = TextLine::new(at(100.0, 100.0), 1.0);
assert!(line.push_upright(&look, at(100.0, 100.0), 1.0, Upright { top: Point::new(110.0, 82.0) }, "§"));
assert!(!line.push(&look, at(130.0, 100.0), 1.0, 10.0, "a"), "a horizontal glyph starts another line");
@@ -288,7 +288,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
stroke: None,
};
// Glyphs set down a column 1.5 em apart (tracking 500): no spaces come in between them.
let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0 };
let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
let mut line = TextLine::new(at(100.0), 1.0);
for (i, c) in ["§", "§", "§"].iter().enumerate() {
let y = 100.0 + 30.0 * i as f64;
+62
View File
@@ -228,3 +228,65 @@ fn a_variable_font_instance_comes_back_in_its_own_style() {
});
assert_eq!(styles, ["Bold"]);
}
/// The export of `text` (one point type object) with its ToUnicode map rewritten so that the
/// digits map to U+FFFD, as some files have it (#708). Each `<003D>` of a digit becomes `<FFFD>`, so
/// the file's offsets still hold.
fn digits_unnamed(text: &str) -> Vec<u8> {
let d = doc(vec![TextObject::point(Point::new(20.0, 50.0), text, style(24.0))]);
let bytes = pdf(&d, false).bytes;
let at = bytes.windows(11).position(|w| w == b"beginbfchar").expect("a readable ToUnicode map");
let end = at + bytes[at..].windows(9).position(|w| w == b"endbfchar").unwrap();
let mut out = bytes.clone();
for i in at..end.saturating_sub(5) {
// `<0030>` … `<0039>` after a space: a digit's character (each line's code comes first).
if &bytes[i..i + 4] == b"<003" && bytes[i + 4].is_ascii_digit() && bytes[i + 5] == b'>' && bytes[i - 1] == b' ' {
out[i + 1..i + 5].copy_from_slice(b"FFFD");
}
}
assert_ne!(out, bytes, "digits were mapped");
out
}
/// #708: a glyph whose ToUnicode value is U+FFFD isn't type showing "�". This subset font has no
/// character map of its own, so the digits keep their outlines, drawn as the file draws them, and
/// a warning names the font.
#[test]
fn glyphs_mapped_to_the_replacement_character_keep_their_outlines() {
let bytes = digits_unnamed("Tel 02-12345");
let report = import_with_report(&bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap();
let all = texts(&report.document).concat();
assert!(!all.contains('\u{FFFD}') && all.contains("Tel"), "no replacement characters: {all:?}");
assert!(ink(&report.document).width() > 50.0, "the digits are outlines");
assert!(report.warnings.iter().any(|w| w.contains("U+FFFD")), "{:?}", report.warnings);
}
/// #708: where the embedded font program has a character map, a glyph's character comes from it.
#[test]
fn an_embedded_fonts_own_cmap_names_its_glyphs() {
let data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf")).unwrap();
let chars = crate::import::font_chars(&data).unwrap();
use skrifa::MetadataProvider;
let font = skrifa::FontRef::new(&data).unwrap();
for c in ['0', '7', 'A', 'z'] {
let g = font.charmap().map(c).unwrap().to_u32();
assert_eq!(chars.get(&g), Some(&c));
}
assert!(crate::import::font_chars(b"not a font").is_none());
}
/// Type whose text matrix shears its glyphs (slanted as a whole, as Illustrator writes type
/// turned and skewed) reopens as type that still leans: drawn, it covers what its outlines do.
#[test]
fn slanted_type_reopens_as_type_that_still_leans() {
for (turn, lean) in [(0.0, 0.25), (-0.2, 0.25), (0.3, -0.15)] {
let mut t = TextObject::point(Point::new(80.0, 150.0), "LEAN", style(60.0));
t.xf *= Affine::rotate(turn) * Affine::skew(lean, 0.0);
let d = doc(vec![t]);
let reopened = import_as(&pdf(&d, false).bytes, TextAs::Text);
assert_eq!(texts(&reopened), ["LEAN"], "{turn} {lean}: reopened as type");
let (a, b) = (ink(&import_as(&pdf(&reopened, true).bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
let off = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
assert!(off < 0.5, "{turn} {lean}: {a:?} vs {b:?}");
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ fn options_not_applied_yet_come_back_as_warnings() {
json!({"output": {"outputIntent": vectorcraft_color::cms::GENERIC_CMYK, "outputCondition": "Press", "registry": "r", "trapped": true}}),
json!({"output": {"outputConditionId": "CGATS TR 001"}}),
json!({"advanced": {"outlineText": false}}),
json!({"advanced": {"fontSubsetPercent": 35}}),
json!({"advanced": {"outlineText": true, "fontSubsetPercent": 35}}),
json!({"bleed": {"useDocument": true, "top": 9}}),
json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}),
json!({"includeNonPrinting": true}),
+4 -3
View File
@@ -85,7 +85,8 @@ pub struct RenderOptions {
pub overprint_preview: bool,
/// Trim View: clip the artwork to the artboards (nothing on the pasteboard is drawn).
pub trim: bool,
/// Leave template layers out (exports and thumbnails: templates are guides, not artwork).
/// Leave template layers and guides out (exports and thumbnails: they're aids to drawing, not
/// artwork).
pub skip_templates: bool,
/// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge
/// Color), RGB.
@@ -725,9 +726,9 @@ impl Renderer {
Some(p)
}
/// Whether `a` is left out of this frame: hidden, a skipped template, or culled.
/// Whether `a` is left out of this frame: hidden, a skipped template or guide, or culled.
fn skipped(&mut self, f: &Frame, a: &Arc<Node>) -> bool {
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. });
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. });
if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) {
return true;
}
+19
View File
@@ -66,3 +66,22 @@ fn dim_images_fades_only_the_images() {
assert_eq!(img.pixel(25, 25), [255, 0, 0, 255], "vector art stays");
assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed");
}
/// A guide path draws on screen only: exports leave it out, as they leave templates out (and as
/// the SVG, PSD and PDF exports and printing already do).
#[test]
fn guides_draw_on_screen_only() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let mut g = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 70.0, 90.0, 90.0)), Appearance::default());
if let NodeKind::Path { guide, .. } = &mut g.kind {
*guide = true;
}
d.insert(Some(l), 0, g).unwrap();
let drawn = |export: bool| {
let r = render(&d, export);
(60..100).flat_map(|y| (60..100).map(move |x| (x, y))).filter(|&(x, y)| r.pixel(x, y) != [255, 255, 255, 255]).count()
};
assert!(drawn(false) > 0, "on screen the guide shows");
assert_eq!(drawn(true), 0, "exports leave it out");
}
+142
View File
@@ -0,0 +1,142 @@
//! Hand-written Illustrator editing data (the format `vectorcraft_eps`'s reader documents) and the
//! EPS and `.ai` files that carry it, for import tests. Nothing here comes from files another app
//! wrote.
/// Editing data of a `width` × `height` page: header comments (the art's bounding box and the crop
/// marks are the page), `body` (setup and layers), the trailer.
pub fn editing_data(width: f64, height: f64, body: &str) -> String {
format!(
"{PS_HEADER}\n%%Creator: VectorCraft tests\n%%BoundingBox: 0 0 {width} {height}\n%%HiResBoundingBox: 0 0 {width} {height}\n\
%AI5_FileFormat 14.0\n%AI3_Cropmarks: 0 0 {width} {height}\n%%EndComments\n\
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n{body}\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"
)
}
/// The first line of a PostScript file (the DSC version comment).
const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment
/// The `PieceInfo` key the private data streams are under.
const PIECE: &str = "Illustrator"; // brand-ok: the key the files use
/// A layer named `name` (shown, unlocked, printing, light blue) holding `art`.
pub fn layer(name: &str, art: &str) -> String {
format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n({name}) Ln\n0 A\n0 Xw\n{art}\nLB\n%AI5_EndLayer--\n")
}
/// The path of the rectangle `x0 y0 x1 y1` (art space: y up).
pub fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
format!("{x0} {y0} m\n{x0} {y1} L\n{x1} {y1} L\n{x1} {y0} L\n{x0} {y0} L\n")
}
/// `name` given to the object before it (its XML id).
pub fn named(name: &str) -> String {
format!("%_/ArtDictionary :\n%_/XMLUID : ({}) ; (AI10_ArtUID) ,\n%_;\n%_\n", name.replace(' ', "_"))
}
/// A document of 200 × 100 pt with a bit of everything the reader reads. A layer `Art` that shows:
/// a group `Pair` (a red square `Red` and a hidden one), a compound path `Ring`, a clipping group
/// `Window` (a grey square seen through a smaller one), what [`page_ps`] and [`page_pdf`] draw. A
/// hidden layer `Hidden`: a radially shaded square `Shaded`, a screened square at half opacity, a
/// locked sublayer `Sub`, and a 2 × 1 image with an alpha channel.
pub fn sample_data() -> Vec<u8> {
let red = "0 1 1 0 1 0 0 Xa\n";
let gradient =
"%AI5_BeginGradient: (Glow)\n(Glow) 1 2 Bd\n[\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n%_0 0 0 1 0 0 0 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
let group = format!(
"0 Ae\nu\n{red}{}f\n{}1 Xw\n0 0 1 0 0 0 1 XA\n2 w\n{}b\n0 Xw\nU\n{}9 () XW\n",
rect(10.0, 50.0, 40.0, 90.0),
named("Red"),
rect(50.0, 50.0, 80.0, 90.0),
named("Pair")
);
let compound = format!("0 Ae\n*u\n{red}1 XR\n{}f\n{}f\n*U\n{}", rect(100.0, 50.0, 140.0, 90.0), rect(110.0, 60.0, 130.0, 80.0), named("Ring"));
let clip =
format!("0 Ae\nq\n0.5 g\n{}f\n{}h\nW\nn\nQ\n{}9 () XW\n", rect(150.0, 50.0, 190.0, 90.0), rect(160.0, 60.0, 180.0, 80.0), named("Window"));
let shaded = format!(
"{}Bb\n1 (Glow) 0 0 0 1 1 0 0 1 0 0 1 Bg\n20 0 0 -20 30 25 Bm\nf\n0 BB\n{}2 0.5 0 0 0 Xy\n{red}{}f\n0 1 0 0 0 Xy\n",
rect(10.0, 5.0, 50.0, 45.0),
named("Shaded"),
rect(60.0, 5.0, 90.0, 45.0)
);
let sub = "%AI5_BeginLayer\n1 1 0 1 0 0 1 2 79 255 79 0 50 0 Lb\n(Sub) Ln\n0 g\n10 10 m\n30 30 L\nS\nLB\n%AI5_EndLayer--\n";
let mut image = b"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n\
[ 20 0 0 20 100 40 ] 2 1 0 Xh\n[ 20 0 0 20 100 40 ] 0 0 2 1 2 1 8 3 1 17 1 0 4 4 0 0\n%%BeginData: 10\rXI\n"
.to_vec();
image.extend_from_slice(&[255, 0, 0, 0, 0, 255, 255, 128]);
image.extend_from_slice(b"%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n");
let head = format!(
"{gradient}{}%AI5_BeginLayer\n0 1 1 1 0 0 0 1 255 79 79 0 50 0 Lb\n(Hidden) Ln\n{shaded}{sub}",
layer("Art", &format!("{group}{compound}{clip}"))
);
let mut body = head.into_bytes();
body.extend_from_slice(&image);
body.extend_from_slice(b"LB\n%AI5_EndLayer--\n");
let mut data = editing_data(200.0, 100.0, "").into_bytes();
let at = data.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap_or(data.len());
data.splice(at..at, body);
data
}
/// The sample's page in PostScript (what shows of it).
pub fn page_ps() -> &'static str {
"1 0 0 setrgbcolor 10 50 30 40 rectfill\n\
newpath 100 50 moveto 140 50 lineto 140 90 lineto 100 90 lineto closepath 110 60 moveto 130 60 lineto 130 80 lineto 110 80 lineto closepath eofill\n\
0.5 setgray 160 60 20 20 rectfill\n"
}
/// The sample's page as PDF content (what shows of it).
pub fn page_pdf() -> &'static str {
"1 0 0 rg 10 50 30 40 re f 100 50 40 40 re 110 60 20 20 re f* 0.5 g 160 60 20 20 re f"
}
/// An Illustrator EPS: its page drawing `page` (PostScript, 200 × 100 pt), and the editing data
/// `data` after it, not compressed.
pub fn eps(data: &[u8], page: &str) -> Vec<u8> {
let mut out = format!(
"{PS_HEADER} EPSF-3.0\n%%Creator: VectorCraft tests\n%%BoundingBox: 0 0 200 100\n%%HiResBoundingBox: 0 0 200 100\n%%EndComments\n{page}showpage\n%%EOF\n%AI9_PrivateDataBegin\n"
)
.into_bytes();
out.extend_from_slice(data);
out.extend_from_slice(b"\n%AI9_PrivateDataEnd\n");
out
}
/// An Illustrator `.ai` file: a 200 × 100 pt PDF page drawing `page` (PDF content), carrying
/// `private` (the private data streams' contents, such as `%AI12_CompressedData` and zlib data) in
/// two streams.
pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
let (first, second) = private.split_at(private.len() / 2);
let stream = |part: &[u8]| {
let mut s = format!("<< /Length {} >>\nstream\n", part.len()).into_bytes();
s.extend_from_slice(part);
s.extend_from_slice(b"\nendstream");
s
};
let objects: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R \
/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> \
/PieceInfo << /{PIECE} << /Private << /AIPrivateData1 5 0 R /AIPrivateData2 6 0 R /NumBlock 2 >> >> >> >>"
)
.into_bytes(),
stream(page.as_bytes()),
stream(first),
stream(second),
];
let mut out = b"%PDF-1.5\n".to_vec();
let mut offsets = vec![];
for (i, o) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend(format!("{} 0 obj\n", i + 1).bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
for off in offsets {
out.extend(format!("{off:010} 00000 n \n").bytes());
}
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
out
}
+2
View File
@@ -10,6 +10,7 @@
//! - [`raster`]: rendering helpers and image comparison with a perceptual tolerance.
//! - [`geom`]: geometry assertions (approximate equality, curve sampling, Hausdorff distance).
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
//! - [`ai`]: hand-written Illustrator editing data, and the EPS and `.ai` files that carry it.
//! - [`ase`]: hand-written swatch exchange (`.ase`) files for swatch library tests.
//!
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
@@ -19,6 +20,7 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#![forbid(unsafe_code)]
pub mod ai;
pub mod ase;
pub mod fixtures;
pub mod geom;
+50 -27
View File
@@ -5,14 +5,16 @@ use std::ops::Range;
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2};
use unicode_bidi::{BidiInfo, Level};
use vectorcraft_doc::{
Burasagari, CharStyle, InlineArt, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject,
Burasagari, CharStyle, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject,
};
use vectorcraft_geom::{ArcPath, PathData};
use crate::composer::{Breakpoint, Params, compose};
use crate::fontdb::FontDb;
use crate::hyphen::hyphen_points;
use crate::shape::{Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, style_metrics};
use crate::shape::{
Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, soft_line_start, style_metrics,
};
use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign};
const EPS: f64 = 1e-6;
@@ -999,7 +1001,7 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S
/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`.
/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it.
fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, burasagari: Burasagari) -> (usize, bool) {
fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, burasagari: Burasagari, kinsoku: Kinsoku) -> (usize, bool) {
if !width.is_finite() {
return (g.len(), false);
}
@@ -1015,7 +1017,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b
let cluster_width: f64 = g[j..cluster_end].iter().map(|glyph| glyph.adv).sum();
if j > i && !gl.is_space() && x + cluster_width > width + EPS {
// It ends the line with the spaces after it.
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1) {
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) {
let mut end = cluster_end;
while g.get(end).is_some_and(SGlyph::is_space) {
end += 1;
@@ -1026,7 +1028,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b
}
x += cluster_width;
let last = &g[cluster_end - 1];
if last.break_after() && kinsoku_allows(g, cluster_end - 1) {
if last.break_after() && kinsoku_allows(g, cluster_end - 1, kinsoku) {
if last.is_soft_hyphen() {
if x + hyphen_glyph(last).adv <= width + EPS {
last_break = Some((cluster_end, true));
@@ -1048,7 +1050,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, b
we += 1;
}
let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum();
let pts: Vec<usize> = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)).collect();
let pts: Vec<usize> = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)).collect();
for &k in pts.iter().rev() {
if k <= j && k > i {
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
@@ -1077,13 +1079,19 @@ fn hangs(g: &SGlyph) -> bool {
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line
/// with the one before it).
fn kinsoku_allows(g: &[SGlyph], j: usize) -> bool {
g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch)))
fn kinsoku_allows(g: &[SGlyph], j: usize, kinsoku: Kinsoku) -> bool {
g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch), kinsoku))
}
/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph).
fn kinsoku_between(before: char, after: Option<char>) -> bool {
!no_line_end(before) && after.is_none_or(|c| !no_line_start(c))
/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph): Hard keeps
/// every character of the set off the line start and end, Soft lets 々, ー and small kana start a
/// line, None allows the break.
fn kinsoku_between(before: char, after: Option<char>, kinsoku: Kinsoku) -> bool {
match kinsoku {
Kinsoku::None => true,
Kinsoku::Hard => !no_line_end(before) && after.is_none_or(|c| !no_line_start(c)),
Kinsoku::Soft => !no_line_end(before) && after.is_none_or(|c| !no_line_start(c) || soft_line_start(c)),
}
}
#[cfg(test)]
@@ -1137,7 +1145,7 @@ mod wrapping_tests {
let text = "بَت";
let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]);
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None);
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None, Kinsoku::Hard);
assert!(!hyphenated);
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
@@ -1150,11 +1158,11 @@ mod wrapping_tests {
let text = "بَتَث";
let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]);
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None);
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, false, Burasagari::None, Kinsoku::Hard);
assert_eq!(first_end, 1);
assert_eq!(source_range(&glyphs, 0, first_end), 0..4);
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, false, Burasagari::None);
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, false, Burasagari::None, Kinsoku::Hard);
assert!(!hyphenated);
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
@@ -1220,12 +1228,27 @@ fn japanese_letters_for_the_latin_space_are_kana_kanji_and_marks_not_hangul_or_p
#[test]
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() {
for (a, b) in [('字', '。'), ('弧', '」'), ('」', '、'), ('ャ', 'ー'), ('カ', 'ッ'), ('「', 'か'), ('(', '雅')] {
assert!(!kinsoku_between(a, Some(b)), "{a}{b}");
assert!(!kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}");
}
for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] {
assert!(kinsoku_between(a, Some(b)), "{a}{b}");
assert!(kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}");
}
assert!(kinsoku_between('。', None, Kinsoku::Hard));
}
/// Soft kinsoku lets 々, ー and small kana (JLREQ cl-09's 々, cl-10, cl-11) start a line and keeps
/// the rest of the set (half-width forms aren't in those classes); None allows any break.
#[cfg(test)]
#[test]
fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() {
for (a, b) in [('ャ', 'ー'), ('カ', 'ッ'), ('時', '々'), ('ト', 'ㇰ'), ('か', 'ゃ')] {
assert!(!kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}");
assert!(kinsoku_between(a, Some(b), Kinsoku::Soft), "{a}{b}");
}
for (a, b) in [('字', '。'), ('弧', '」'), ('「', 'か'), ('カ', 'ッ'), ('カ', 'ー'), ('字', 'ゝ')] {
assert!(!kinsoku_between(a, Some(b), Kinsoku::Soft), "{a}{b}");
assert!(kinsoku_between(a, Some(b), Kinsoku::None), "{a}{b}");
}
assert!(kinsoku_between('。', None));
}
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go.
@@ -1255,26 +1278,26 @@ fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
}
/// Break candidates for the every-line composer.
fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
fn candidates(text: &str, g: &[SGlyph], hyphenate: bool, kinsoku: Kinsoku) -> Vec<Breakpoint> {
let mut v = vec![];
let mut ws = 0;
for (j, gl) in g.iter().enumerate() {
let end_of_word = gl.is_space() || gl.break_after();
if end_of_word {
if hyphenate && j > ws {
for k in hyphen_breaks(text, g, ws, j).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
for k in hyphen_breaks(text, g, ws, j).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) {
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
}
}
let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 };
if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j) {
if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j, kinsoku) {
v.push(Breakpoint { end: j + 1, hyphen: hy });
}
ws = j + 1;
}
}
if hyphenate && g.len() > ws {
for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) {
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
}
}
@@ -1323,7 +1346,7 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
let width = |k: usize| widths.get(k).copied().unwrap_or(last);
// Lines from `uniform_from` on are interchangeable (same width).
let uniform_from = widths.iter().rposition(|&w| (w - last).abs() > EPS).map_or(0, |k| k + 1);
let cands = candidates(cx.text, sg, para.hyphenate);
let cands = candidates(cx.text, sg, para.hyphenate, para.kinsoku);
let justify_last = para.justify == Justify::JustifyAll;
let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from };
// Ragged: first look for breaks that leave at most one rag zone (a sixth of the width) on
@@ -1383,10 +1406,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let est = if i < n { Metrics::of(&sg[i]) } else { pm };
let first_line = li_para == 0;
let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 };
// Mojikumi: an opening bracket starting a wrapped line is set flush with the line's
// start (the space before it goes), and the line has that much more room.
if !first_line
&& !rtl
// Mojikumi: an opening bracket starting a line is set flush with the line's start (the
// space before it goes), and the line has that much more room. At a paragraph's start
// that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①).
if !rtl
&& para.mojikumi == Mojikumi::LineEndHalf
&& let Some(g) = sg.get_mut(i)
&& g.lead <= 0.0
@@ -1406,7 +1429,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let width = x1 - x0 - ind_l - para.right_indent;
let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) {
Some(&(e, h)) if e > i => (e, h),
_ if i < n => break_line(cx.text, &sg, i, width, para.hyphenate, para.burasagari),
_ if i < n => break_line(cx.text, &sg, i, width, para.hyphenate, para.burasagari, para.kinsoku),
_ => (n, false),
};
let m = Metrics::max(&sg[i..end]).unwrap_or(pm);
+35
View File
@@ -133,6 +133,41 @@ pub(crate) fn no_line_start(c: char) -> bool {
| '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ')
}
/// Soft kinsoku: a character of [`no_line_start`] that may start a line all the same: 々, the
/// prolonged sound mark ー (JLREQ cl-10) and small kana (cl-11), as JLREQ's level 3 rules allow
/// (Appendix C.3, https://www.w3.org/TR/jlreq/#addendum_a3).
pub(crate) fn soft_line_start(c: char) -> bool {
matches!(
c,
'々' | 'ー'
| '\u{3041}'
| '\u{3043}'
| '\u{3045}'
| '\u{3047}'
| '\u{3049}'
| '\u{3063}'
| '\u{3083}'
| '\u{3085}'
| '\u{3087}'
| '\u{308E}'
| '\u{3095}'
| '\u{3096}'
| '\u{30A1}'
| '\u{30A3}'
| '\u{30A5}'
| '\u{30A7}'
| '\u{30A9}'
| '\u{30C3}'
| '\u{30E3}'
| '\u{30E5}'
| '\u{30E7}'
| '\u{30EE}'
| '\u{30F5}'
| '\u{30F6}'
| '\u{31F0}'..='\u{31FF}'
)
}
/// Kinsoku: a character that can't end a line (opening brackets).
pub(crate) fn no_line_end(c: char) -> bool {
matches!(
+47 -12
View File
@@ -353,15 +353,16 @@ fn mojikumi_halves_line_end_and_consecutive_punctuation() {
// 」 before 「: the closing mark's space goes; 「 keeps its own.
let a = adv("一」「二", Mojikumi::LineEndHalf);
assert!((a[1] - 10.0).abs() < 0.01 && (a[2] - 20.0).abs() < 0.01, "{a:?}");
// 「「: the second bracket loses the space before it and is drawn half an em earlier.
let a = adv("「「一", Mojikumi::LineEndHalf);
assert!((a[0] - 20.0).abs() < 0.01 && (a[1] - 10.0).abs() < 0.01, "{a:?}");
let (on, off) = (lay("「「一", Mojikumi::LineEndHalf, vertical_type), lay("「「一", Mojikumi::None, vertical_type));
// 「「 inside a line: the second bracket loses the space before it and is drawn half an em
// earlier (at a line's start the first one would lose its own too).
let a = adv("一「「二", Mojikumi::LineEndHalf);
assert!((a[1] - 20.0).abs() < 0.01 && (a[2] - 10.0).abs() < 0.01, "{a:?}");
let (on, off) = (lay("一「「二", Mojikumi::LineEndHalf, vertical_type), lay("一「「二", Mojikumi::None, vertical_type));
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box());
let moved = along(off.glyphs[2].outline.bounding_box()) - along(on.glyphs[2].outline.bounding_box());
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: the second 「 is drawn half an em earlier ({moved})");
let across = |r: Rect| if vertical_type { r.x0 } else { r.y0 };
let drift = across(off.glyphs[1].outline.bounding_box()) - across(on.glyphs[1].outline.bounding_box());
let drift = across(off.glyphs[2].outline.bounding_box()) - across(on.glyphs[2].outline.bounding_box());
assert!(drift.abs() < 0.01, "vertical {vertical_type}: and not moved across the line ({drift})");
// Text without punctuation is untouched.
assert_eq!(adv("一二", Mojikumi::LineEndHalf), adv("一二", Mojikumi::None));
@@ -394,8 +395,8 @@ fn mojikumi_spaces_japanese_from_latin_by_a_quarter_em() {
// Latin first, and nothing after the last character of the line.
assert_eq!(extra("AB雅").iter().map(|x| x.round()).collect::<Vec<_>>(), [0.0, 5.0, 0.0]);
assert_eq!(extra("雅A").iter().map(|x| x.round()).collect::<Vec<_>>(), [5.0, 0.0]);
// Punctuation takes no Japanese–Latin space (the closing bracket isn't at the line's end).
assert!(extra("「A」です").iter().all(|x| x.abs() < 0.01), "{:?}", extra("「A」です"));
// Punctuation takes no Japanese–Latin space (neither bracket is at the line's start or end).
assert!(extra("で「A」です").iter().all(|x| x.abs() < 0.01), "{:?}", extra("で「A」です"));
}
// A tate-chu-yoko block is set as a Japanese character, with no space inside or around it.
let on = lay("第10回", Mojikumi::LineEndHalf, true);
@@ -405,8 +406,9 @@ fn mojikumi_spaces_japanese_from_latin_by_a_quarter_em() {
/// Mojikumi (JLREQ 3.1.5): with Line-end Punctuation Half Width, an opening bracket starting a
/// wrapped line is set flush with the line's start (the space before it goes), giving the line half
/// an em more room; at the start of a paragraph it keeps its full width. Horizontal and vertical.
/// Needs a font with full-width Japanese punctuation (skipped without one).
/// an em more room; at the start of a paragraph too, so its face starts at the first-line indent
/// (JIS X 4051's principle, Figure 71 ①). Horizontal and vertical. Needs a font with full-width
/// Japanese punctuation (skipped without one).
#[test]
fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() {
use vectorcraft_doc::Mojikumi;
@@ -436,9 +438,13 @@ fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() {
// The half em it gave up lets 六 stay on the line.
assert_eq!(on.glyphs[6].line, on.glyphs[3].line, "vertical {vertical_type}");
assert_ne!(off.glyphs[6].line, off.glyphs[3].line, "vertical {vertical_type}");
// At the start of a paragraph the bracket keeps its full width.
// At the start of a paragraph too: half width, its face half an em earlier than with
// Mojikumi None, so it starts at the first-line indent.
let first = lay("「一」", Mojikumi::LineEndHalf, vertical_type);
assert!((first.glyphs[0].advance - 20.0).abs() < 0.01, "vertical {vertical_type}: {}", first.glyphs[0].advance);
assert!((first.glyphs[0].advance - 10.0).abs() < 0.01, "vertical {vertical_type}: {}", first.glyphs[0].advance);
let solid = lay("「一」", Mojikumi::None, vertical_type);
let moved = along(solid.glyphs[0].outline.bounding_box()) - along(first.glyphs[0].outline.bounding_box());
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: the paragraph's 「 is drawn half an em earlier ({moved})");
}
}
@@ -504,3 +510,32 @@ fn burasagari_hangs_a_comma_or_full_stop_outside_the_line() {
let l = lay("一二三四五、六七", Burasagari::Standard, false);
assert!((l.glyphs[5].origin.x - 100.0).abs() < 0.01, "{:?}", l.glyphs[5].origin);
}
/// Kinsoku Set: in a measure of two ems, あカッ breaks before ッ with Soft (a small kana may start a
/// line) and before カ with Hard (ッ goes with the character before it); None breaks as Soft here.
/// Horizontal and vertical. Needs a font with full-width Japanese (skipped without one).
#[test]
fn kinsoku_set_decides_whether_a_small_kana_may_start_a_line() {
use vectorcraft_doc::Kinsoku;
let lay = |text: &str, k: Kinsoku, vertical_type: bool| {
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.vertical = vertical_type;
t.para.kinsoku = k;
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 40.0) } else { Rect::new(0.0, 0.0, 40.0, 200.0) };
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
layout(FontDb::global(), &t)
};
if (lay("雅", Kinsoku::Hard, false).glyphs[0].advance - 20.0).abs() > 2.0 {
return; // no font with full-width Japanese here
}
for vertical_type in [false, true] {
let lines = |k| lay("あカッ", k, vertical_type).glyphs.iter().map(|g| g.line).collect::<Vec<_>>();
assert_eq!(lines(Kinsoku::Hard), [0, 1, 1], "vertical {vertical_type}");
assert_eq!(lines(Kinsoku::Soft), [0, 0, 1], "vertical {vertical_type}");
assert_eq!(lines(Kinsoku::None), [0, 0, 1], "vertical {vertical_type}");
// A full stop never starts a line, Soft or not.
let stop = |k| lay("あカ。", k, vertical_type).glyphs.iter().map(|g| g.line).collect::<Vec<_>>();
assert_eq!(stop(Kinsoku::Soft), [0, 1, 1], "vertical {vertical_type}");
}
}
+5
View File
@@ -517,6 +517,11 @@ pub trait Tool: Send {
fn busy(&self) -> bool {
false
}
/// Is a drag moving, scaling or rotating the selection (the canvas then hides the bounding box,
/// so only the art is seen going with the pointer)?
fn transforming(&self) -> bool {
false
}
/// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool
/// with a stop selected takes Delete and the arrows)?
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
+4
View File
@@ -163,6 +163,10 @@ impl Tool for SelectionTool {
!matches!(self.state, State::Idle) || self.guide.busy()
}
fn transforming(&self) -> bool {
matches!(self.state, State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. })
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if let Some(out) = self.guide.pointer(cx, ev) {
return out;
+1 -1
View File
@@ -34,7 +34,7 @@ const ROWS: &[Row] = &[
(&["polygon"], None, &["sides"]),
(&["star"], None, &["points"]),
(&["freeTransform"], None, &["constrain"]),
(&["artboard"], None, &["moveArt"]),
(&["artboard"], None, &["moveArt", "scaleArt"]),
];
/// The stores `tool`'s persistent options live in, each with the option keys it holds.
+88 -8
View File
@@ -3,15 +3,16 @@
//! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
//! leaves the artboard where it was), drag a handle resizes it
//! (Shift proportional, Alt from centre), drag on the pasteboard draws a new artboard, Delete removes
//! (Shift proportional, Alt from centre; with the `scaleArt` option on, Scale Artwork with Artboard,
//! always proportional, and the art fully inside scales with it), drag on the pasteboard draws a new artboard, Delete removes
//! the active one (Copy, Cut and Paste take it with its art: `artboard.copy`) and Escape returns to
//! the Selection tool. Moving and resizing snap like drawing
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
//! objects (never to the dragged artboard or the art moving with it); the artboard's own bleed edges
//! objects (never to the dragged artboard or the art moving or scaling with it); the artboard's own bleed edges
//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]).
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use super::{BLUE, polygon, rect_corners};
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
@@ -49,6 +50,9 @@ pub struct ArtboardTool {
pub active: usize,
/// Move artwork with the artboard.
pub move_art: bool,
/// Scale artwork with the artboard: a resize takes the art fully inside it along, and keeps the
/// artboard's proportions.
pub scale_art: bool,
drag: Option<Drag>,
preview: Option<Rect>,
/// Smart Guide targets, gathered when a move or resize starts.
@@ -63,7 +67,17 @@ pub struct ArtboardTool {
impl Default for ArtboardTool {
fn default() -> Self {
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![], draw: DrawSnap::default() }
Self {
active: 0,
move_art: true,
scale_art: false,
drag: None,
preview: None,
targets: None,
copy_targets: None,
guides: vec![],
draw: DrawSnap::default(),
}
}
}
@@ -140,6 +154,20 @@ impl ArtboardTool {
}
}
/// An artboard's shortest side (as `artboard.setProps` keeps it).
const MIN_SIDE: f64 = 1.0;
/// `r` grown about `anchor` until both sides are at least [`MIN_SIDE`], by one factor, so a
/// proportional resize keeps its proportions where the command would lengthen only the short side.
fn keep_min_side(r: Rect, anchor: Point) -> Rect {
let short = r.width().min(r.height());
if short >= MIN_SIDE || short <= 0.0 || !short.is_finite() {
return r;
}
let k = MIN_SIDE / short;
(Affine::translate(anchor.to_vec2()) * Affine::scale(k) * Affine::translate(-anchor.to_vec2())).transform_rect_bbox(r)
}
/// Where the bleed edge lies beyond a handle, along the axes the handle moves.
fn bleed_offset(cx: &ToolContext, rect: Rect, handle: Handle) -> Vec2 {
let d = handle.pos(cx.doc.setup.bleed_rect(rect)) - handle.pos(rect);
@@ -206,13 +234,21 @@ impl Tool for ArtboardTool {
let mut out = vec![];
if !began {
out.push(Action::Begin("Resize Artboard".into()));
self.begin_snapping(cx, index, false);
self.begin_snapping(cx, index, self.scale_art);
}
self.drag = Some(Drag::Resize { index, handle, rect, grab, began: true });
let h = self.snap_handle(cx, p + grab, bleed_offset(cx, rect, handle));
let nr = scale_for_drag(rect, handle, h, m.shift, m.alt).transform_rect_bbox(rect);
let proportional = m.shift || self.scale_art;
let mut nr = scale_for_drag(rect, handle, h, proportional, m.alt).transform_rect_bbox(rect);
if proportional {
nr = keep_min_side(nr, if m.alt { rect.center() } else { handle.opposite().pos(rect) });
}
self.preview = Some(nr);
out.push(Action::Preview("artboard.setProps".into(), rect_json(index, nr)));
let mut v = rect_json(index, nr);
if self.scale_art {
v["scaleArt"] = json!(true);
}
out.push(Action::Preview("artboard.setProps".into(), v));
out
}
(PointerKind::Drag, Some(Drag::Create { start, .. })) => {
@@ -338,13 +374,14 @@ impl Tool for ArtboardTool {
}
fn options(&self) -> Value {
json!({ "active": self.active, "moveArt": self.move_art })
json!({ "active": self.active, "moveArt": self.move_art, "scaleArt": self.scale_art })
}
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"active" => self.active = value.as_u64().unwrap_or(0) as usize,
"moveArt" => self.move_art = value.as_bool().unwrap_or(true),
"scaleArt" => self.scale_art = value.as_bool().unwrap_or(false),
_ => {}
}
}
@@ -483,6 +520,49 @@ mod tests {
assert_eq!(v["width"].as_f64(), Some(210.0));
}
/// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's
/// proportions, and never snaps to the art scaling with it.
#[test]
fn scale_art_resizes_proportionally_without_snapping_to_its_art() {
let mut d = two_boards();
let l = d.layers[0].id;
let id = d.alloc_id();
let art = vectorcraft_geom::shapes::rectangle(Rect::new(700.0, 50.0, 710.0, 60.0));
d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
// Off: the right handle 3 pt past the art's right edge (710) lands on it; the height stays.
let mut t = ArtboardTool { active: 1, ..Default::default() };
t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0)));
assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v.get("scaleArt")), (Some(110.0), Some(200.0), None));
t.pointer(&c, &ev(PointerKind::Up, 713.0, 100.0));
// On: the art scales along, so it's no target, and the height follows the width.
t.set_option("scaleArt", &json!(true));
assert_eq!(t.options()["scaleArt"], true);
t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0)));
assert_eq!((v["width"].as_f64(), v["scaleArt"].as_bool()), (Some(113.0), Some(true)));
assert!((v["height"].as_f64().unwrap() - 113.0).abs() < 1e-9);
}
/// A proportional resize never asks for a side under 1 pt, which the command would lengthen
/// alone (scaling the art unevenly): both sides grow by one factor instead.
#[test]
fn a_proportional_resize_keeps_its_proportions_at_the_smallest_size() {
let mut d = two_boards();
d.artboards[1].rect = Rect::new(600.0, 0.0, 800.0, 20.0);
let (s, p) = (Selection::default(), paint());
let mut c = cx(&d, &s, &p);
c.smart_guides = false;
let mut t = ArtboardTool { active: 1, scale_art: true, ..Default::default() };
// The right handle to 2.5 % of the width: 5 × 0.5, grown to 10 × 1 about the left middle.
t.pointer(&c, &ev(PointerKind::Down, 800.0, 10.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 605.0, 10.0)));
let got = ["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap());
assert!(got.iter().zip([600.0, 9.5, 10.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?}");
}
#[test]
fn bleed_edges_snap_to_bleed_edges() {
let mut d = two_boards();
+25 -3
View File
@@ -32,6 +32,7 @@
mod centerline;
mod contour;
mod fit;
mod logo;
mod mosaic;
mod quantize;
@@ -53,6 +54,10 @@ pub enum TraceError {
Empty,
#[error("the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels)")]
TooLarge { width: u32, height: u32, max: u64 },
#[error("this isn't a flat-colour logo ({:.0}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead", (.fraction * 100.0).floor())]
NotFlat { fraction: f64 },
#[error("\"{0}\" is not a colour: write it as #rrggbb")]
BadPalette(String),
#[error(
"this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset"
)]
@@ -127,6 +132,9 @@ pub enum Mode {
Grayscale,
#[serde(alias = "Color")]
Color,
/// Flat-colour logos: sub-pixel edges, sharp corners restored, true lines and circles.
#[serde(alias = "Logo", alias = "flat", alias = "flatLogo")]
Logo,
}
/// How colour layers relate (Advanced → Method).
@@ -187,12 +195,14 @@ pub struct TraceParams {
pub paths: f64,
/// Corners 0–100: higher keeps more corners.
pub corners: f64,
/// Noise: areas smaller than this many pixels are ignored.
/// Noise: areas smaller than this many pixels are ignored. (Flat Logo mode: areas smaller than
/// `noise / 10` pixels of the source image.)
pub noise: u32,
pub method: Method,
/// Drop white areas (no white background shape).
pub ignore_white: bool,
/// Replace nearly-straight curves with straight lines.
/// Replace nearly-straight curves with straight lines. In Logo mode this also snaps tight arcs
/// to true circles.
pub snap_curves_to_lines: bool,
/// Create: trace areas as filled shapes.
pub fills: bool,
@@ -200,6 +210,10 @@ pub struct TraceParams {
pub strokes: bool,
/// Stroke: the widest feature (px) traced as a stroke.
pub stroke_width: f64,
/// Flat Logo mode: the flat colours (`#rrggbb`) to use; empty finds them automatically. White is
/// always traced on its own and cannot be named, colours closer than about 12 (RGB distance)
/// count as one, and at most ten are used. (Not `palette`: that is Color mode's colour source.)
pub logo_colors: Vec<String>,
}
impl Default for TraceParams {
@@ -222,6 +236,7 @@ impl Default for TraceParams {
fills: true,
strokes: false,
stroke_width: 10.0,
logo_colors: Vec::new(),
}
}
}
@@ -258,6 +273,7 @@ pub const PRESET_NAMES: &[&str] = &[
"Silhouettes",
"Line Art",
"Technical Drawing",
"Flat Logo",
];
/// A built-in preset by name (case-insensitive; `[Default]` is accepted).
@@ -296,6 +312,8 @@ pub fn preset(name: &str) -> Option<TraceParams> {
"silhouettes" => bw(200, 40.0, 60.0, 30, false),
"line art" => bw(128, 80.0, 70.0, 10, false),
"technical drawing" => bw(128, 95.0, 90.0, 4, true),
// Flat-colour logos: colours found automatically, edges placed to a fraction of a pixel.
"flat logo" => TraceParams { mode: Mode::Logo, colors: 8, paths: 60.0, corners: 50.0, noise: 6, snap_curves_to_lines: true, ..d },
_ => return None,
})
}
@@ -337,7 +355,8 @@ fn is_white(c: [u8; 3]) -> bool {
c.iter().all(|&v| v >= 245)
}
/// Trace `img` with `params`.
/// Trace `img` with `params`. Failures (an image Logo mode refuses, say) give an empty result: use
/// [`trace_within`] to see why.
pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult {
// No trace has more than `usize::MAX` anchors.
trace_within(img, params, usize::MAX).unwrap_or_default()
@@ -351,6 +370,9 @@ pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> R
if w == 0 || h == 0 || img.rgba.len() != w * h * 4 {
return Ok(TraceResult::default());
}
if params.mode == Mode::Logo {
return logo::trace_logo(img, params, max_anchors);
}
let mut q = quantize(img, params);
denoise(&mut q.labels, w, h, params.noise as usize);
// Pixel count per palette entry.
+196
View File
@@ -0,0 +1,196 @@
//! Least-squares cubic Bézier fitting (Schneider's algorithm): fit one cubic to a run of points,
//! improve its parametrisation, and split where the error is largest until every piece is within
//! the tolerance. After P. J. Schneider, "An algorithm for automatically fitting digitized curves"
//! (Graphics Gems, 1990), written from the description there.
use super::geom::{Cubic, bez};
use kurbo::Vec2;
use vectorcraft_geom::Point;
const MAX_DEPTH: usize = 14;
fn chord(p: &[Point]) -> Vec<f64> {
let mut d = vec![0.0];
for w in p.windows(2) {
d.push(d.last().copied().unwrap_or(0.0) + w[0].distance(w[1]));
}
let total = d.last().copied().unwrap_or(0.0);
if total > 0.0 {
d.iter_mut().for_each(|v| *v /= total);
}
d
}
fn fit_one(p: &[Point], u: &[f64], t1: Vec2, t2: Vec2) -> Option<Cubic> {
let (p0, p3) = (*p.first()?, *p.last()?);
let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0);
for (q, &t) in p.iter().zip(u) {
let s = 1.0 - t;
let (b0, b1, b2, b3) = (s * s * s, 3.0 * t * s * s, 3.0 * t * t * s, t * t * t);
let (a1, a2) = (t1 * b1, t2 * b2);
c00 += a1.dot(a1);
c01 += a1.dot(a2);
c11 += a2.dot(a2);
let tmp = *q - (p0.to_vec2() * (b0 + b1) + p3.to_vec2() * (b2 + b3)).to_point();
x0 += a1.dot(tmp);
x1 += a2.dot(tmp);
}
let det = c00 * c11 - c01 * c01;
let seg = p0.distance(p3);
let (mut al, mut ar) = if det.abs() > 1e-12 { ((x0 * c11 - x1 * c01) / det, (c00 * x1 - c01 * x0) / det) } else { (0.0, 0.0) };
if !al.is_finite() || !ar.is_finite() || al <= 1e-6 * seg || ar <= 1e-6 * seg {
al = seg / 3.0;
ar = seg / 3.0;
}
Some([p0, p0 + t1 * al, p3 + t2 * ar, p3])
}
fn reparam(c: &Cubic, p: &[Point], u: &[f64]) -> Vec<f64> {
p.iter()
.zip(u)
.map(|(q, &t)| {
let s = 1.0 - t;
let d = bez(c, t) - *q;
let (v0, v1, v2) = (c[1] - c[0], c[2] - c[1], c[3] - c[2]);
let d1 = (v0 * (s * s) + v1 * (2.0 * s * t) + v2 * (t * t)) * 3.0;
let d2 = ((v1 - v0) * s + (v2 - v1) * t) * 6.0;
let den = d1.dot(d1) + d.dot(d2);
if den.abs() > 1e-12 { (t - d.dot(d1) / den).clamp(0.0, 1.0) } else { t }
})
.collect()
}
fn max_err(c: &Cubic, p: &[Point], u: &[f64]) -> (f64, usize) {
let mut best = (0.0, p.len() / 2);
for (i, (q, &t)) in p.iter().zip(u).enumerate() {
let e = bez(c, t).distance(*q);
if e > best.0 {
best = (e, i);
}
}
best
}
fn rec(p: &[Point], t1: Vec2, t2: Vec2, tol: f64, depth: usize, out: &mut Vec<Cubic>) {
let (Some(&a), Some(&b)) = (p.first(), p.last()) else { return };
if p.len() == 2 {
let d = a.distance(b) / 3.0;
out.push([a, a + t1 * d, b + t2 * d, b]);
return;
}
let mut u = chord(p);
let Some(mut c) = fit_one(p, &u, t1, t2) else { return };
for _ in 0..4 {
let (e, _) = max_err(&c, p, &u);
if e < tol {
out.push(c);
return;
}
if e > tol * 6.0 {
break;
}
u = reparam(&c, p, &u);
match fit_one(p, &u, t1, t2) {
Some(n) => c = n,
None => break,
}
}
let (e, split) = max_err(&c, p, &u);
if e < tol || depth >= MAX_DEPTH || p.len() < 6 {
out.push(c);
return;
}
let s = split.clamp(2, p.len() - 3);
let tc = match (p.get(s - 1), p.get(s + 1)) {
(Some(&x), Some(&y)) if x.distance(y) > 1e-12 => (x - y) / x.distance(y),
_ => t1,
};
rec(p.get(..=s).unwrap_or(p), t1, tc, tol, depth + 1, out);
rec(p.get(s..).unwrap_or(p), -tc, t2, tol, depth + 1, out);
}
/// Cubics through `p` (first and last points are interpolated) with end tangents `t1` (pointing
/// along the run at its start) and `t2` (pointing back along the run at its end), each within
/// `tol` of the points.
pub(crate) fn fit_cubics(p: &[Point], t1: Vec2, t2: Vec2, tol: f64) -> Vec<Cubic> {
let mut out = Vec::new();
if p.len() >= 2 && tol > 0.0 && p.iter().all(|q| q.x.is_finite() && q.y.is_finite()) {
rec(p, t1, t2, tol, 0, &mut out);
}
out
}
/// Unit direction from `p[i]` towards `p[i + k]` (`sign > 0`) or `p[i - k]` (`sign < 0`).
pub(crate) fn tangent(p: &[Point], i: usize, k: usize, sign: i32) -> Vec2 {
let j = if sign > 0 { (i + k).min(p.len().saturating_sub(1)) } else { i.saturating_sub(k) };
match (p.get(i), p.get(j)) {
(Some(&a), Some(&b)) if a.distance(b) > 1e-12 => (b - a) / a.distance(b),
_ => Vec2::new(1.0, 0.0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logo::geom::sample_cubics;
fn dev(cs: &[Cubic], pts: &[Point]) -> f64 {
let s = sample_cubics(cs, 0.01);
pts.iter().map(|p| s.iter().map(|q| q.distance(*p)).fold(f64::MAX, f64::min)).fold(0.0, f64::max)
}
#[test]
fn a_circle_arc_is_one_cubic() {
let pts: Vec<Point> = (0..200).map(|i| Point::new(20.0 * (i as f64 * 0.006).cos(), 20.0 * (i as f64 * 0.006).sin())).collect();
let cs = fit_cubics(&pts, tangent(&pts, 0, 4, 1), tangent(&pts, 199, 4, -1), 0.05);
assert_eq!(cs.len(), 1);
assert!(dev(&cs, &pts) < 0.05);
}
#[test]
fn a_full_circle_stays_within_tolerance_with_few_pieces() {
let n = 1200;
let mut pts: Vec<Point> = (0..=n)
.map(|i| {
Point::new(
50.0 + 20.0 * (i as f64 / n as f64 * std::f64::consts::TAU).cos(),
50.0 + 20.0 * (i as f64 / n as f64 * std::f64::consts::TAU).sin(),
)
})
.collect();
pts[n] = pts[0];
let t = (pts[1] - pts[n - 1]) / pts[1].distance(pts[n - 1]);
let cs = fit_cubics(&pts, t, -t, 0.05);
assert!(cs.len() <= 12, "{} pieces", cs.len());
assert!(dev(&cs, &pts) < 0.06, "deviation {}", dev(&cs, &pts));
}
#[test]
fn a_wavy_curve_is_fitted_within_tolerance() {
let pts: Vec<Point> = (0..600).map(|i| Point::new(i as f64 * 0.05, 3.0 * (i as f64 * 0.02).sin())).collect();
let cs = fit_cubics(&pts, tangent(&pts, 0, 4, 1), tangent(&pts, 599, 4, -1), 0.04);
assert!(dev(&cs, &pts) < 0.05);
assert!(cs.len() < 20);
}
#[test]
fn pieces_join_end_to_end() {
let pts: Vec<Point> = (0..400).map(|i| Point::new(i as f64 * 0.1, (i as f64 * 0.05).sin() * 4.0)).collect();
let cs = fit_cubics(&pts, tangent(&pts, 0, 4, 1), tangent(&pts, 399, 4, -1), 0.02);
for w in cs.windows(2) {
assert!(w[0][3].distance(w[1][0]) < 1e-9);
}
}
#[test]
fn degenerate_input_gives_nothing_or_a_line_not_a_panic() {
let t = Vec2::new(1.0, 0.0);
assert!(fit_cubics(&[], t, t, 0.1).is_empty());
assert!(fit_cubics(&[Point::new(0.0, 0.0)], t, t, 0.1).is_empty());
assert_eq!(fit_cubics(&[Point::new(0.0, 0.0), Point::new(3.0, 0.0)], t, -t, 0.1).len(), 1);
assert!(fit_cubics(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0), Point::new(2.0, 0.0)], t, t, 0.1).is_empty());
assert!(fit_cubics(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(2.0, 0.0)], t, t, 0.0).is_empty());
// Duplicate points do not divide by zero.
let _ = fit_cubics(&[Point::new(1.0, 1.0); 10], t, t, 0.1);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Smooth sub-pixel fields: cubic B-spline interpolation of a coverage plane.
//!
//! Method: cubic B-spline interpolation with a recursive prefilter, as described in M. Unser, A.
//! Aldroubi and M. Eden, "B-spline signal processing" (IEEE Trans. Signal Processing, 1993) and P.
//! Thévenaz, T. Blu and M. Unser, "Interpolation revisited" (IEEE Trans. Medical Imaging, 2000).
//! Written from those papers.
//!
//! Interpolating coverage (an area fraction), not colour, and then comparing classes, puts an
//! edge where two classes are equally present, which is accurate to a fraction of a pixel.
/// Gaussian blur of a `w × h` plane (σ in pixels, edges repeated). Linear, so per-pixel weights that
/// sum to one across planes still do.
pub(crate) fn blur_plane(plane: &mut [f32], w: usize, h: usize, sigma: f64) {
if w == 0 || h == 0 || plane.len() < w * h || sigma.is_nan() || sigma <= 0.0 {
return;
}
let r = (3.0 * sigma).ceil() as i64;
let k: Vec<f64> = (-r..=r).map(|i| (-0.5 * (i as f64 / sigma).powi(2)).exp()).collect();
let sum: f64 = k.iter().sum();
let at = |src: &[f32], x: i64, y: i64| {
f64::from(src.get(y.clamp(0, h as i64 - 1) as usize * w + x.clamp(0, w as i64 - 1) as usize).copied().unwrap_or(0.0))
};
let mut tmp = vec![0.0f32; w * h];
for y in 0..h as i64 {
for x in 0..w as i64 {
let v: f64 = k.iter().enumerate().map(|(j, kv)| kv * at(plane, x + j as i64 - r, y)).sum();
if let Some(d) = tmp.get_mut(y as usize * w + x as usize) {
*d = (v / sum) as f32;
}
}
}
for y in 0..h as i64 {
for x in 0..w as i64 {
let v: f64 = k.iter().enumerate().map(|(j, kv)| kv * at(&tmp, x, y + j as i64 - r)).sum();
if let Some(d) = plane.get_mut(y as usize * w + x as usize) {
*d = (v / sum) as f32;
}
}
}
}
/// Replace samples by cubic B-spline coefficients so that interpolation passes through them
/// (Unser's recursive filter, mirror boundaries).
fn prefilter_line(c: &mut [f64]) {
let n = c.len();
if n < 2 {
return;
}
let z = 3.0f64.sqrt() - 2.0;
let gain = (1.0 - z) * (1.0 - 1.0 / z);
for v in c.iter_mut() {
*v *= gain;
}
// Causal initial value: the exact mirror-boundary sum for short lines, a truncated one for long.
let at = |k: usize| c.get(k).copied().unwrap_or(0.0);
let horizon = ((1e-9f64).ln() / z.abs().ln()).ceil() as usize;
let first = if horizon < n {
let mut zk = z;
let mut sum = at(0);
for k in 1..horizon {
sum += zk * at(k);
zk *= z;
}
sum
} else {
let iz = 1.0 / z;
let mut zk = z;
let mut z2n = z.powi(n as i32 - 1);
let mut sum = at(0) + z2n * at(n - 1);
z2n = z2n * z2n * iz;
for k in 1..n - 1 {
sum += (zk + z2n) * at(k);
zk *= z;
z2n *= iz;
}
sum / (1.0 - zk * zk)
};
if let Some(v) = c.first_mut() {
*v = first;
}
for k in 1..n {
let prev = c.get(k - 1).copied().unwrap_or(0.0);
if let Some(v) = c.get_mut(k) {
*v += z * prev;
}
}
let (a, b) = (c.get(n - 2).copied().unwrap_or(0.0), c.get(n - 1).copied().unwrap_or(0.0));
if let Some(last) = c.last_mut() {
*last = (z / (z * z - 1.0)) * (z * a + b);
}
for k in (0..n - 1).rev() {
let next = c.get(k + 1).copied().unwrap_or(0.0);
if let Some(v) = c.get_mut(k) {
*v = z * (next - *v);
}
}
}
/// B-spline coefficients of a `w × h` plane.
fn coefficients(plane: &[f32], w: usize, h: usize) -> Vec<f64> {
let mut c: Vec<f64> = plane.iter().map(|&v| f64::from(v)).collect();
for row in c.chunks_mut(w.max(1)) {
prefilter_line(row);
}
let mut col = vec![0.0f64; h];
for x in 0..w {
for (y, v) in col.iter_mut().enumerate() {
*v = c.get(y * w + x).copied().unwrap_or(0.0);
}
prefilter_line(&mut col);
for (y, v) in col.iter().enumerate() {
if let Some(dst) = c.get_mut(y * w + x) {
*dst = *v;
}
}
}
c
}
/// First tap and the four cubic B-spline weights for every output sample along one axis.
fn taps(n_in: usize, u: usize) -> Vec<(i64, [f64; 4])> {
(0..n_in * u)
.map(|i| {
let x = (i as f64 + 0.5) / u as f64 - 0.5;
let i0 = x.floor();
let t = x - i0;
let t2 = t * t;
let t3 = t2 * t;
let w = [(1.0 - t).powi(3) / 6.0, (3.0 * t3 - 6.0 * t2 + 4.0) / 6.0, (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0) / 6.0, t3 / 6.0];
(i0 as i64 - 1, w)
})
.collect()
}
/// The plane smoothly upsampled `u` times (sample centres stay aligned), clamped to `0..=1`.
pub(crate) fn upsample(plane: &[f32], w: usize, h: usize, u: usize) -> Vec<f32> {
if w == 0 || h == 0 || u == 0 || plane.len() < w * h {
return Vec::new();
}
let coef = coefficients(plane, w, h);
let (tx, ty) = (taps(w, u), taps(h, u));
let ow = w * u;
// Whole-sample mirror boundaries, matching the prefilter.
let clampi = |v: i64, n: usize| {
if n < 2 {
return 0;
}
let period = 2 * (n as i64 - 1);
let m = v.rem_euclid(period);
(if m < n as i64 { m } else { period - m }) as usize
};
let mut tmp = vec![0.0f64; h * ow];
for y in 0..h {
let row = coef.get(y * w..(y + 1) * w).unwrap_or(&[]);
for (ox, &(base, wt)) in tx.iter().enumerate() {
let mut s = 0.0;
for (j, wj) in wt.iter().enumerate() {
s += wj * row.get(clampi(base + j as i64, w)).copied().unwrap_or(0.0);
}
if let Some(d) = tmp.get_mut(y * ow + ox) {
*d = s;
}
}
}
let mut out = vec![0.0f32; ow * h * u];
for (oy, &(base, wt)) in ty.iter().enumerate() {
let rows: [usize; 4] = std::array::from_fn(|j| clampi(base + j as i64, h));
for ox in 0..ow {
let mut s = 0.0;
for (j, wj) in wt.iter().enumerate() {
s += wj * tmp.get(rows[j] * ow + ox).copied().unwrap_or(0.0);
}
if let Some(d) = out.get_mut(oy * ow + ox) {
*d = s.clamp(0.0, 1.0) as f32;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_constant_plane_stays_constant() {
let up = upsample(&[0.5; 6 * 5], 6, 5, 4);
assert_eq!(up.len(), 24 * 20);
assert!(up.iter().all(|&v| (v - 0.5).abs() < 1e-4));
}
#[test]
fn interpolation_passes_through_the_samples() {
// Sample (x, y) lives at the centre of its u×u block, i.e. between output samples; with
// u = 1 the output is the input.
let plane: Vec<f32> = (0..30).map(|i| ((i * 7) % 11) as f32 / 11.0).collect();
let up = upsample(&plane, 6, 5, 1);
for (a, b) in plane.iter().zip(&up) {
assert!((a - b).abs() < 1e-4, "{a} vs {b}");
}
}
#[test]
fn a_blurred_step_crosses_half_where_the_edge_is() {
// A vertical edge half-way through column 4: columns 0..4 are 0, column 4 is 0.5, then 1.
let (w, h) = (9, 3);
let plane: Vec<f32> = (0..w * h)
.map(|i| match i % w {
0..=3 => 0.0,
4 => 0.5,
_ => 1.0,
})
.collect();
let u = 8;
let up = upsample(&plane, w, h, u);
let row = &up[(h * u / 2) * (w * u)..(h * u / 2 + 1) * (w * u)];
let x = row.iter().position(|&v| v >= 0.5).unwrap_or(0);
// The 0.5 crossing sits at the centre of column 4 → output sample 4.5 * u = 36.
assert!((x as f64 - 36.0).abs() <= 1.5, "crossing at {x}");
}
#[test]
fn degenerate_input_is_empty_not_a_panic() {
assert!(upsample(&[], 0, 0, 4).is_empty());
assert!(upsample(&[1.0], 1, 1, 0).is_empty());
assert!(upsample(&[1.0], 2, 2, 3).is_empty());
}
}
+394
View File
@@ -0,0 +1,394 @@
//! Small geometry helpers for outlines: resampling, smoothing, corners, line and circle fits.
use kurbo::Vec2;
use std::collections::HashMap;
use vectorcraft_geom::Point;
/// One cubic Bézier segment: start, two handles, end.
pub(crate) type Cubic = [Point; 4];
/// Longest outline (in points) any step will build, whatever the input.
pub(crate) const MAX_POINTS: usize = 400_000;
pub(crate) fn finite(p: Point) -> bool {
p.x.is_finite() && p.y.is_finite()
}
pub(crate) fn bez(c: &Cubic, t: f64) -> Point {
let u = 1.0 - t;
let (a, b, cc, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
Point::new(a * c[0].x + b * c[1].x + cc * c[2].x + d * c[3].x, a * c[0].y + b * c[1].y + cc * c[2].y + d * c[3].y)
}
/// A closed polyline resampled at (about) `step` spacing; also returns the exact spacing used.
pub(crate) fn resample_closed(p: &[Point], step: f64) -> Option<(Vec<Point>, f64)> {
let n = p.len();
if n < 3 || step.is_nan() || step <= 0.0 || !p.iter().all(|&q| finite(q)) {
return None;
}
let mut cum = Vec::with_capacity(n + 1);
cum.push(0.0);
for i in 0..n {
let (a, b) = (*p.get(i)?, *p.get((i + 1) % n)?);
cum.push(cum.last().copied().unwrap_or(0.0) + a.distance(b));
}
let total = cum.last().copied().unwrap_or(0.0);
if total.is_nan() || total <= step {
return None;
}
let m = ((total / step) as usize).clamp(8, MAX_POINTS);
let mut out = Vec::with_capacity(m);
let mut j = 0usize;
for i in 0..m {
let s = total * i as f64 / m as f64;
while cum.get(j + 1).is_some_and(|&c| c < s) {
j += 1;
}
let (c0, c1) = (*cum.get(j)?, *cum.get(j + 1)?);
let t = if c1 > c0 { (s - c0) / (c1 - c0) } else { 0.0 };
let (a, b) = (*p.get(j % n)?, *p.get((j + 1) % n)?);
out.push(a + (b - a) * t);
}
Some((out, total / m as f64))
}
fn kernel(sigma: f64) -> Vec<f64> {
let s = sigma.max(0.3);
let r = ((3.0 * s).ceil() as i64).max(1);
let k: Vec<f64> = (-r..=r).map(|i| (-0.5 * (i as f64 / s).powi(2)).exp()).collect();
let sum: f64 = k.iter().sum();
k.iter().map(|v| v / sum).collect()
}
/// Gaussian smoothing of a closed polyline (σ in samples).
pub(crate) fn smooth_closed(p: &[Point], sigma: f64) -> Vec<Point> {
let n = p.len() as i64;
if n < 3 {
return p.to_vec();
}
let k = kernel(sigma);
let r = (k.len() as i64 - 1) / 2;
(0..n)
.map(|i| {
let mut s = Vec2::ZERO;
for (j, w) in k.iter().enumerate() {
let idx = (i + j as i64 - r).rem_euclid(n) as usize;
if let Some(q) = p.get(idx) {
s += q.to_vec2() * *w;
}
}
s.to_point()
})
.collect()
}
/// Gaussian smoothing of an open run with both ends pinned (the run is extended by odd reflection
/// so tangents at the ends survive).
pub(crate) fn smooth_open(p: &[Point], sigma: f64) -> Vec<Point> {
let n = p.len();
if n < 5 {
return p.to_vec();
}
let s = sigma.max(0.4).min(n as f64 / 8.0).max(0.4);
let k = kernel(s);
let r = (k.len() - 1) / 2;
let r_ext = r.min(n - 2);
let (first, last) = match (p.first(), p.last()) {
(Some(&f), Some(&l)) => (f, l),
_ => return p.to_vec(),
};
let mut ext: Vec<Point> = Vec::with_capacity(n + 2 * r_ext);
for i in (1..=r_ext).rev() {
ext.push(first + (first - p.get(i).copied().unwrap_or(first)));
}
ext.extend_from_slice(p);
for i in 1..=r_ext {
ext.push(last + (last - p.get(n - 1 - i).copied().unwrap_or(last)));
}
let kk = if r_ext == r { k } else { kernel_of_radius(s, r_ext) };
let rr = (kk.len() - 1) / 2;
let mut out: Vec<Point> = (0..n)
.map(|i| {
let mut acc = Vec2::ZERO;
for (j, w) in kk.iter().enumerate() {
let idx = i + r_ext + j;
if let Some(q) = idx.checked_sub(rr).and_then(|x| ext.get(x)) {
acc += q.to_vec2() * *w;
}
}
acc.to_point()
})
.collect();
if let (Some(f), Some(l)) = (out.first_mut(), p.first()) {
*f = *l;
}
if let (Some(f), Some(l)) = (out.last_mut(), p.last()) {
*f = *l;
}
out
}
fn kernel_of_radius(sigma: f64, r: usize) -> Vec<f64> {
let k: Vec<f64> = (-(r as i64)..=r as i64).map(|i| (-0.5 * (i as f64 / sigma).powi(2)).exp()).collect();
let sum: f64 = k.iter().sum();
k.iter().map(|v| v / sum).collect()
}
/// Turning angle (degrees) at each point of a closed polyline over a window of `w` samples.
pub(crate) fn turning(p: &[Point], w: usize) -> Vec<f64> {
let n = p.len();
(0..n)
.map(|i| {
let (Some(&c), Some(&a), Some(&b)) = (p.get(i), p.get((i + n - w % n) % n), p.get((i + w) % n)) else { return 0.0 };
let (v1, v2) = (c - a, b - c);
(v1.cross(v2)).atan2(v1.dot(v2)).abs().to_degrees()
})
.collect()
}
/// Local maxima of the turning angle above `thr` degrees, at least `w` samples apart.
pub(crate) fn find_corners(p: &[Point], w: usize, thr: f64) -> Vec<usize> {
let n = p.len();
if n < 8 || w == 0 {
return Vec::new();
}
let ang = turning(p, w);
let at = |i: usize| ang.get(i % n).copied().unwrap_or(0.0);
let mut idx: Vec<usize> = Vec::new();
for i in 0..n {
let a = at(i);
if a >= thr && a >= at(i + n - 1) && a > at(i + 1) && idx.last().is_none_or(|&l| i - l > w) {
idx.push(i);
}
}
if idx.len() > 1
&& let (Some(&f), Some(&l)) = (idx.first(), idx.last())
&& f + n - l <= w
{
idx.pop();
}
idx
}
/// A straight line through `pts`: centroid, unit direction (first → last) and the largest
/// distance of any point from the line.
pub(crate) struct Line {
pub c: Point,
pub d: Vec2,
pub resid: f64,
}
pub(crate) fn line_fit(pts: &[Point]) -> Option<Line> {
let n = pts.len();
if n < 2 {
return None;
}
let c = (pts.iter().fold(Vec2::ZERO, |a, p| a + p.to_vec2()) / n as f64).to_point();
let (mut sxx, mut sxy, mut syy) = (0.0, 0.0, 0.0);
for p in pts {
let v = *p - c;
sxx += v.x * v.x;
sxy += v.x * v.y;
syy += v.y * v.y;
}
let ang = 0.5 * (2.0 * sxy).atan2(sxx - syy);
let mut d = Vec2::new(ang.cos(), ang.sin());
if d.dot(*pts.last()? - *pts.first()?) < 0.0 {
d = -d;
}
let nrm = Vec2::new(-d.y, d.x);
let resid = pts.iter().map(|p| (*p - c).dot(nrm).abs()).fold(0.0, f64::max);
(resid.is_finite()).then_some(Line { c, d, resid })
}
fn solve3(m: [f64; 9], b: [f64; 3]) -> Option<[f64; 3]> {
let det = m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6]);
if det.abs() < 1e-12 {
return None;
}
let rep = |col: usize| {
let mut t = m;
for r in 0..3 {
t[r * 3 + col] = b[r];
}
t[0] * (t[4] * t[8] - t[5] * t[7]) - t[1] * (t[3] * t[8] - t[5] * t[6]) + t[2] * (t[3] * t[7] - t[4] * t[6])
};
Some([rep(0) / det, rep(1) / det, rep(2) / det])
}
/// A circle through `pts` (algebraic fit): centre, radius, largest radial error.
pub(crate) struct Circle {
pub c: Point,
pub r: f64,
pub resid: f64,
}
pub(crate) fn circle_fit(pts: &[Point]) -> Option<Circle> {
if pts.len() < 3 {
return None;
}
// Centre the data for conditioning.
let m = (pts.iter().fold(Vec2::ZERO, |a, p| a + p.to_vec2()) / pts.len() as f64).to_point();
let (mut a, mut b) = ([0.0f64; 9], [0.0f64; 3]);
for p in pts {
let (x, y) = (p.x - m.x, p.y - m.y);
let row = [2.0 * x, 2.0 * y, 1.0];
let rhs = x * x + y * y;
for i in 0..3 {
for j in 0..3 {
a[i * 3 + j] += row[i] * row[j];
}
b[i] += row[i] * rhs;
}
}
let s = solve3(a, b)?;
let r2 = s[2] + s[0] * s[0] + s[1] * s[1];
if r2.is_nan() || r2 <= 1e-12 {
return None;
}
let (c, r) = (Point::new(s[0] + m.x, s[1] + m.y), r2.sqrt());
let resid = pts.iter().map(|p| (p.distance(c) - r).abs()).fold(0.0, f64::max);
(resid.is_finite() && r.is_finite()).then_some(Circle { c, r, resid })
}
/// Cubic Béziers approximating the arc of radius `r` about `c` from angle `a0` to `a1`.
pub(crate) fn arc_cubics(c: Point, r: f64, a0: f64, a1: f64) -> Vec<Cubic> {
let n = ((a1 - a0).abs() / std::f64::consts::FRAC_PI_2).ceil().clamp(1.0, 64.0) as usize;
let da = (a1 - a0) / n as f64;
let k = 4.0 / 3.0 * (da / 4.0).tan();
(0..n)
.map(|j| {
let (s, e) = (a0 + j as f64 * da, a0 + (j + 1) as f64 * da);
let p0 = Point::new(c.x + r * s.cos(), c.y + r * s.sin());
let p3 = Point::new(c.x + r * e.cos(), c.y + r * e.sin());
[p0, p0 + Vec2::new(-s.sin(), s.cos()) * (k * r), p3 - Vec2::new(-e.sin(), e.cos()) * (k * r), p3]
})
.collect()
}
/// Points along the curves, about `spacing` apart.
pub(crate) fn sample_cubics(cs: &[Cubic], spacing: f64) -> Vec<Point> {
let mut out = Vec::new();
for c in cs {
let len = (c[1] - c[0]).hypot() + (c[2] - c[1]).hypot() + (c[3] - c[2]).hypot();
let n = ((len / spacing.max(1e-3)) as usize).clamp(8, 20_000);
out.extend((0..=n).map(|i| bez(c, i as f64 / n as f64)));
if out.len() > MAX_POINTS * 4 {
break;
}
}
out
}
/// True when every point of `a` has a point of `b` within `lim`.
pub(crate) fn all_within(a: &[Point], b: &[Point], lim: f64) -> bool {
if lim.is_nan() || lim <= 0.0 {
return false;
}
let key = |p: Point| ((p.x / lim).floor() as i64, (p.y / lim).floor() as i64);
let mut grid: HashMap<(i64, i64), Vec<Point>> = HashMap::new();
for &q in b {
grid.entry(key(q)).or_default().push(q);
}
a.iter().all(|&p| {
let (kx, ky) = key(p);
(-1..=1).any(|dx| (-1..=1).any(|dy| grid.get(&(kx + dx, ky + dy)).is_some_and(|v| v.iter().any(|q| q.distance(p) <= lim))))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn circle_pts(n: usize, r: f64) -> Vec<Point> {
(0..n)
.map(|i| {
Point::new(
50.0 + r * (i as f64 / n as f64 * std::f64::consts::TAU).cos(),
50.0 + r * (i as f64 / n as f64 * std::f64::consts::TAU).sin(),
)
})
.collect()
}
#[test]
fn resampling_makes_even_spacing() {
let (r, step) = resample_closed(&circle_pts(37, 20.0), 0.5).expect("resample");
assert!((step - 0.5).abs() < 0.01);
let gaps: Vec<f64> = (0..r.len()).map(|i| r[i].distance(r[(i + 1) % r.len()])).collect();
assert!(gaps.iter().all(|g| (g - step).abs() < 0.02), "{gaps:?}");
}
#[test]
fn resampling_refuses_degenerate_input() {
assert!(resample_closed(&[], 1.0).is_none());
assert!(resample_closed(&[Point::new(0.0, 0.0); 5], 1.0).is_none());
assert!(resample_closed(&circle_pts(10, 5.0), 0.0).is_none());
assert!(resample_closed(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 0.0), Point::new(0.0, 1.0)], 0.1).is_none());
}
#[test]
fn smoothing_a_circle_keeps_its_radius() {
let s = smooth_closed(&circle_pts(600, 20.0), 4.0);
for p in &s {
assert!((p.distance(Point::new(50.0, 50.0)) - 20.0).abs() < 0.05);
}
}
#[test]
fn open_smoothing_pins_the_ends() {
let run: Vec<Point> = (0..60).map(|i| Point::new(i as f64 * 0.2, (i as f64 * 0.7).sin() * 0.05)).collect();
let s = smooth_open(&run, 3.0);
assert_eq!((s[0], s[59]), (run[0], run[59]));
assert_eq!(s.len(), run.len());
}
#[test]
fn a_square_has_four_corners() {
let sq: Vec<Point> = (0..400)
.map(|i| {
let t = i as f64 / 100.0;
match t as usize {
0 => Point::new(t * 10.0, 0.0),
1 => Point::new(10.0, (t - 1.0) * 10.0),
2 => Point::new(10.0 - (t - 2.0) * 10.0, 10.0),
_ => Point::new(0.0, 10.0 - (t - 3.0) * 10.0),
}
})
.collect();
assert_eq!(find_corners(&sq, 6, 60.0).len(), 4);
}
#[test]
fn a_circle_has_no_corners() {
assert!(find_corners(&circle_pts(500, 20.0), 6, 40.0).is_empty());
}
#[test]
fn line_and_circle_fits_recover_their_shapes() {
let line: Vec<Point> = (0..50).map(|i| Point::new(i as f64, 2.0 * i as f64 + 1.0)).collect();
let l = line_fit(&line).expect("line");
assert!(l.resid < 1e-9 && l.d.x > 0.0);
let c = circle_fit(&circle_pts(100, 12.5)).expect("circle");
assert!((c.r - 12.5).abs() < 1e-6 && c.c.distance(Point::new(50.0, 50.0)) < 1e-6 && c.resid < 1e-6);
assert!(circle_fit(&line).is_none() || circle_fit(&line).is_some_and(|c| c.r > 1e3 || c.resid > 1.0));
assert!(line_fit(&[Point::new(0.0, 0.0)]).is_none());
}
#[test]
fn arcs_stay_on_the_circle() {
let cs = arc_cubics(Point::new(5.0, 5.0), 10.0, 0.3, 4.0);
for p in sample_cubics(&cs, 0.05) {
assert!((p.distance(Point::new(5.0, 5.0)) - 10.0).abs() < 0.01);
}
}
#[test]
fn within_checks_both_near_and_far() {
let a = circle_pts(100, 10.0);
let b = circle_pts(100, 10.2);
assert!(all_within(&a, &b, 0.5));
assert!(!all_within(&a, &b, 0.1));
assert!(!all_within(&a, &b, 0.0));
}
}
+175
View File
@@ -0,0 +1,175 @@
//! Marching squares: closed contours of a scalar field at level 0.
//!
//! Segments keep the positive side on their left (x right, y down), so a region's outer boundary
//! and its holes wind in opposite directions and need no point-in-polygon test to tell apart.
use std::collections::HashMap;
use vectorcraft_geom::Point;
/// Edge ids: `(y * w + x) * 2` is the horizontal edge from `(x, y)` to `(x + 1, y)`, `+ 1` the
/// vertical edge from `(x, y)` to `(x, y + 1)`.
fn edge_point(f: &[f32], w: usize, id: usize) -> Point {
let cell = id / 2;
let (x, y) = (cell % w, cell / w);
let at = |xx: usize, yy: usize| f64::from(f.get(yy * w + xx).copied().unwrap_or(0.0));
if id.is_multiple_of(2) {
let (a, b) = (at(x, y), at(x + 1, y));
let t = if (a - b).abs() < 1e-12 { 0.5 } else { (a / (a - b)).clamp(0.0, 1.0) };
Point::new(x as f64 + t, y as f64)
} else {
let (a, b) = (at(x, y), at(x, y + 1));
let t = if (a - b).abs() < 1e-12 { 0.5 } else { (a / (a - b)).clamp(0.0, 1.0) };
Point::new(x as f64, y as f64 + t)
}
}
/// Closed loops (grid coordinates, sample `(x, y)` at `(x, y)`) around the region where `f > 0`.
/// Loops reaching the grid border are not closed by this function: pad the field with negatives.
pub(crate) fn contours(f: &[f32], w: usize, h: usize) -> Vec<Vec<Point>> {
if w < 2 || h < 2 || f.len() < w * h {
return Vec::new();
}
let mut next: HashMap<usize, usize> = HashMap::new();
for y in 0..h - 1 {
for x in 0..w - 1 {
let v = |xx: usize, yy: usize| f.get(yy * w + xx).copied().unwrap_or(-1.0);
let (tl, tr, br, bl) = (v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1));
let case = usize::from(tl > 0.0) | usize::from(tr > 0.0) << 1 | usize::from(br > 0.0) << 2 | usize::from(bl > 0.0) << 3;
if case == 0 || case == 15 {
continue;
}
let c = y * w + x;
let (t, r, b, l) = (c * 2, (c + 1) * 2 + 1, (c + w) * 2, c * 2 + 1);
let centre = (tl + tr + br + bl) * 0.25 > 0.0;
let segs: &[(usize, usize)] = match case {
1 => &[(l, t)],
2 => &[(t, r)],
3 => &[(l, r)],
4 => &[(r, b)],
5 => {
if centre {
&[(r, t), (l, b)]
} else {
&[(l, t), (r, b)]
}
}
6 => &[(t, b)],
7 => &[(l, b)],
8 => &[(b, l)],
9 => &[(b, t)],
10 => {
if centre {
&[(t, l), (b, r)]
} else {
&[(t, r), (b, l)]
}
}
11 => &[(b, r)],
12 => &[(r, l)],
13 => &[(r, t)],
14 => &[(t, l)],
_ => &[],
};
for &(from, to) in segs {
next.insert(from, to);
}
}
}
let mut loops = Vec::new();
let mut starts: Vec<usize> = next.keys().copied().collect();
starts.sort_unstable();
for s in starts {
if !next.contains_key(&s) {
continue;
}
let mut pts = Vec::new();
let mut cur = s;
let mut closed = false;
for _ in 0..next.len() + 1 {
let Some(n) = next.remove(&cur) else { break };
pts.push(edge_point(f, w, cur));
cur = n;
if cur == s {
closed = true;
break;
}
}
if closed && pts.len() >= 3 {
loops.push(pts);
}
}
loops
}
/// Twice the signed area of a closed polygon (shoelace, x right, y down). With the positive side
/// on the left, the **outer** boundary of a region is negative and its **holes** are positive.
pub(crate) fn area2(p: &[Point]) -> f64 {
let n = p.len();
(0..n)
.map(|i| {
let (a, b) = (p.get(i).copied().unwrap_or_default(), p.get((i + 1) % n).copied().unwrap_or_default());
a.x * b.y - b.x * a.y
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
/// `f` = positive inside a disc of radius `r` at `(cx, cy)`, on a padded grid.
fn disc(w: usize, h: usize, cx: f64, cy: f64, r: f64) -> Vec<f32> {
(0..w * h).map(|i| (r - ((i % w) as f64 - cx).hypot((i / w) as f64 - cy)) as f32).collect()
}
#[test]
fn a_disc_gives_one_loop_of_the_right_size() {
let f = disc(40, 40, 20.0, 20.0, 9.0);
let loops = contours(&f, 40, 40);
assert_eq!(loops.len(), 1);
let a = area2(&loops[0]).abs() / 2.0;
assert!((a - std::f64::consts::PI * 81.0).abs() / (std::f64::consts::PI * 81.0) < 0.01, "area {a}");
// Every vertex is on the circle to well under a hundredth of a cell.
for p in &loops[0] {
assert!(((p.x - 20.0).hypot(p.y - 20.0) - 9.0).abs() < 0.05);
}
}
#[test]
fn outer_boundaries_and_holes_wind_in_opposite_directions() {
// A ring: positive between radius 5 and 10.
let f: Vec<f32> = (0..50 * 50)
.map(|i| {
let d = ((i % 50) as f64 - 25.0).hypot((i / 50) as f64 - 25.0);
(5.0 - (d - 7.5).abs()) as f32
})
.collect();
let loops = contours(&f, 50, 50);
assert_eq!(loops.len(), 2);
let mut a: Vec<f64> = loops.iter().map(|l| area2(l)).collect();
a.sort_by(f64::total_cmp);
assert!(a[0] < 0.0 && a[1] > 0.0, "outer and hole must differ in sign: {a:?}");
// The outer loop is the bigger one and has the negative area; the hole is positive.
assert!(a[0].abs() > a[1].abs());
}
#[test]
fn two_separate_blobs_give_two_loops() {
let mut f = disc(60, 30, 15.0, 15.0, 6.0);
let g = disc(60, 30, 45.0, 15.0, 6.0);
for (a, b) in f.iter_mut().zip(&g) {
*a = a.max(*b);
}
assert_eq!(contours(&f, 60, 30).len(), 2);
}
#[test]
fn saddles_and_degenerate_grids_do_not_panic() {
// A checkerboard is all saddles.
let f: Vec<f32> = (0..64).map(|i| if (i % 8 + i / 8) % 2 == 0 { 1.0 } else { -1.0 }).collect();
let _ = contours(&f, 8, 8);
assert!(contours(&[], 0, 0).is_empty());
assert!(contours(&[1.0], 1, 1).is_empty());
assert!(contours(&[1.0; 3], 4, 4).is_empty());
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Logo mode: flat-colour logos traced to clean, sub-pixel-accurate vector shapes.
//!
//! The tracer in the other modes follows hard pixel edges, so an outline can only be as good as
//! the pixel grid. Here each pixel is read as a *blend* of the logo's flat colours, the blend is
//! interpolated smoothly, and every outline is placed where two colours are equally present, to a
//! fraction of a pixel. The outlines are then cleaned the way a person would: corners the blur
//! rounded are put back, straight runs and circular arcs become exact lines and arcs, and the
//! rest is fitted with as few Bézier curves as the tolerance allows. Areas of transparency
//! fully enclosed by artwork (letters knocked out of a badge, the counters of an "o") are painted
//! white, so the logo reads the same on any background; with Ignore White they stay real holes.
//! Flat-colour logos only: gradients and photos are refused ([`TraceError::NotFlat`](crate::TraceError)).
mod bezier;
mod field;
mod geom;
mod march;
mod outline;
mod palette;
mod pipeline;
mod restore;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod testutil;
mod unmix;
pub(crate) use pipeline::trace_logo;
+306
View File
@@ -0,0 +1,306 @@
//! One outline: smooth, restore corners, snap to lines and arcs, fit Béziers, and verify.
use super::bezier::{fit_cubics, tangent};
use super::geom::{Cubic, all_within, arc_cubics, circle_fit, find_corners, finite, resample_closed, sample_cubics, smooth_closed, smooth_open};
use super::restore::{RestoreParams, restore_corners};
use vectorcraft_geom::Point;
/// How an outline is fitted. Lengths are in (working) pixels.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Style {
pub step: f64,
pub sigma: f64,
pub corner_deg: f64,
pub corner_win: f64,
pub tol: f64,
pub snap: bool,
pub line_tol: f64,
pub circle_tol: f64,
pub restore: Option<RestoreParams>,
/// An outline whose fit strays farther than this from the raw contour is fitted plainly.
pub max_dev: f64,
}
impl Default for Style {
fn default() -> Self {
Self {
step: 0.12,
sigma: 0.5,
corner_deg: 60.0,
corner_win: 0.7,
tol: 0.12,
snap: true,
line_tol: 0.22,
circle_tol: 0.25,
restore: Some(RestoreParams::default()),
max_dev: 1.4,
}
}
}
/// What fitting did, for tests and diagnostics.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Stats {
pub restored: usize,
pub lines: usize,
pub arcs: usize,
pub circles: usize,
pub reverted: usize,
}
fn line_cubic(a: Point, b: Point) -> Cubic {
let d = b - a;
[a, a + d / 3.0, a + d * (2.0 / 3.0), b]
}
/// The sweep of angles from the first to the last point of `pts` about `c` (continuous, may exceed π).
fn sweep(pts: &[Point], c: Point) -> Option<(f64, f64)> {
let ang = |p: &Point| (p.y - c.y).atan2(p.x - c.x);
let mut prev = ang(pts.first()?);
let a0 = prev;
let mut total = 0.0;
for p in pts.iter().skip(1) {
let a = ang(p);
let mut d = a - prev;
while d > std::f64::consts::PI {
d -= std::f64::consts::TAU;
}
while d < -std::f64::consts::PI {
d += std::f64::consts::TAU;
}
total += d;
prev = a;
}
Some((a0, a0 + total))
}
/// A plain fit of the closed raw contour: no smoothing, corners or snapping.
fn plain_fit(p: &[Point], tol: f64) -> Vec<Cubic> {
let n = p.len();
let (Some(&first), Some(&second), Some(&last)) = (p.first(), p.get(1), p.get(n.saturating_sub(1))) else { return Vec::new() };
let t = second - last;
let len = t.hypot();
if len.is_nan() || len <= 1e-12 {
return Vec::new();
}
let t = t / len;
let mut closed = p.to_vec();
closed.push(first);
fit_cubics(&closed, t, -t, tol)
}
fn styled_fit(p: &[Point], st: &Style, stats: &mut Stats) -> Option<Vec<Cubic>> {
let (mut r, step) = resample_closed(p, st.step)?;
let sg = st.sigma / step;
let light = (0.18 / step).max(1.0);
let mut s0 = smooth_closed(&r, light);
let mut forced: Vec<usize> = Vec::new();
if let Some(rp) = &st.restore {
let (nr, f) = restore_corners(&r, &s0, step, rp);
if !f.is_empty() {
stats.restored += f.len();
s0 = nr.clone();
r = nr;
forced = f;
}
}
let w = ((st.corner_win / step).round() as usize).max(2);
let mut corners = find_corners(&s0, w, st.corner_deg);
corners.extend(forced);
corners.sort_unstable();
corners.dedup();
let n = r.len();
if corners.is_empty() {
let s = smooth_closed(&r, sg);
if st.snap
&& let Some(c) = circle_fit(&s)
&& c.resid < st.circle_tol
&& c.r > 2.5
{
// Keep the loop's own direction and starting point: holes must stay opposite to
// their outlines or the winding rule would add them instead of cancelling.
if let Some((a0, a1)) = sweep(&s, c.c) {
let dir = if a1 >= a0 { 1.0 } else { -1.0 };
stats.circles += 1;
return Some(arc_cubics(c.c, c.r, a0, a0 + dir * std::f64::consts::TAU));
}
}
let (Some(&a), Some(&b), Some(&z)) = (s.first(), s.get(1), s.last()) else { return None };
let t = b - z;
let len = t.hypot();
if len.is_nan() || len <= 1e-12 {
return None;
}
let t = t / len;
let mut q = s;
q.push(a);
return Some(fit_cubics(&q, t, -t, st.tol));
}
let mut segs: Vec<Cubic> = Vec::new();
for (ci, &i0) in corners.iter().enumerate() {
let i1 = *corners.get((ci + 1) % corners.len())?;
let count = if i1 <= i0 { i1 + n - i0 } else { i1 - i0 } + 1;
let run: Vec<Point> = (0..count).filter_map(|k| r.get((i0 + k) % n).copied()).collect();
let (Some(&first), Some(&last)) = (run.first(), run.last()) else { continue };
if run.len() < 4 {
if run.len() >= 2 && first.distance(last) > 1e-9 {
segs.push(line_cubic(first, last));
}
continue;
}
let sm = smooth_open(&run, sg);
let (Some(&a), Some(&b)) = (sm.first(), sm.last()) else { continue };
let len = a.distance(b);
if st.snap && len > 1.5 {
let d = b - a;
let nrm = kurbo::Vec2::new(-d.y, d.x) / len;
if sm.iter().all(|q| (*q - a).dot(nrm).abs() < st.line_tol) {
stats.lines += 1;
segs.push(line_cubic(a, b));
continue;
}
}
if st.snap
&& sm.len() > 12
&& len > 1.0
&& let Some(c) = circle_fit(&sm)
&& c.resid < st.circle_tol
&& c.r > 2.5
&& c.r < 400.0
&& let Some((a0, a1)) = sweep(&sm, c.c)
&& (a1 - a0).abs() > 0.35
{
stats.arcs += 1;
let first = segs.len();
segs.extend(arc_cubics(c.c, c.r, a0, a1));
// The fitted circle may miss the corners by up to the snap tolerance: end exactly on them.
if let Some(s) = segs.get_mut(first) {
s[0] = a;
}
if let Some(s) = segs.last_mut() {
s[3] = b;
}
continue;
}
segs.extend(fit_cubics(&sm, tangent(&sm, 0, 4, 1), tangent(&sm, sm.len() - 1, 4, -1), st.tol));
}
(!segs.is_empty()).then_some(segs)
}
fn sane(segs: &[Cubic]) -> bool {
!segs.is_empty() && segs.iter().all(|c| c.iter().all(|&p| finite(p)))
}
/// The outline `p` (a closed loop) as cubic Béziers, or `None` if it is too small or degenerate.
pub(crate) fn fit_outline(p: &[Point], st: &Style, stats: &mut Stats) -> Option<Vec<Cubic>> {
if p.len() < 3 || !p.iter().all(|&q| finite(q)) {
return None;
}
let mut local = Stats::default();
// The reference is the raw outline, densified so the check never depends on how sparse it is.
let reference = resample_closed(p, 0.1).map_or_else(|| p.to_vec(), |(d, _)| d);
let fitted = styled_fit(p, st, &mut local).filter(|s| sane(s)).filter(|s| {
let samples = sample_cubics(s, 0.03);
all_within(&samples, &reference, st.max_dev) && all_within(&reference, &samples, st.max_dev)
});
match fitted {
Some(segs) => {
stats.restored += local.restored;
stats.lines += local.lines;
stats.arcs += local.arcs;
stats.circles += local.circles;
Some(segs)
}
None => {
stats.reverted += 1;
Some(plain_fit(p, st.tol)).filter(|s| sane(s))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::logo::testutil::{circle, rounded_square};
fn near_any(segs: &[Cubic], target: Point) -> f64 {
sample_cubics(segs, 0.01).iter().map(|q| q.distance(target)).fold(f64::MAX, f64::min)
}
#[test]
fn a_rounded_square_becomes_a_sharp_square_with_straight_sides() {
let mut st = Stats::default();
let segs = fit_outline(&rounded_square(0.7), &Style::default(), &mut st).expect("fit");
assert_eq!(st.restored, 4);
assert_eq!(st.lines, 4, "{st:?}");
assert_eq!(segs.len(), 4, "a square is four lines");
for (x, y) in [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)] {
assert!(near_any(&segs, Point::new(x, y)) < 0.1);
}
assert_eq!(st.reverted, 0);
}
#[test]
fn a_disc_becomes_a_true_circle() {
let mut st = Stats::default();
let segs = fit_outline(&circle(25.0, 25.0, 12.0, 700), &Style::default(), &mut st).expect("fit");
assert_eq!(st.circles, 1);
assert_eq!(segs.len(), 4);
// Smoothing shrinks a curve by about sigma^2 / (2 r): 0.01 px here.
for q in sample_cubics(&segs, 0.05) {
assert!((q.distance(Point::new(25.0, 25.0)) - 12.0).abs() < 0.03);
}
}
#[test]
fn a_snapped_circle_keeps_the_direction_of_its_loop() {
let forward = circle(25.0, 25.0, 12.0, 700);
let mut backward = forward.clone();
backward.reverse();
let signed = |segs: &[Cubic]| {
let p = sample_cubics(segs, 0.1);
let n = p.len();
(0..n).map(|i| p[i].x * p[(i + 1) % n].y - p[(i + 1) % n].x * p[i].y).sum::<f64>()
};
let (mut a, mut b) = (Stats::default(), Stats::default());
let f = fit_outline(&forward, &Style::default(), &mut a).expect("forward");
let r = fit_outline(&backward, &Style::default(), &mut b).expect("backward");
assert_eq!((a.circles, b.circles), (1, 1));
assert!(signed(&f) > 0.0 && signed(&r) < 0.0, "directions {} and {}", signed(&f), signed(&r));
}
#[test]
fn without_snapping_a_circle_is_still_smooth_and_close() {
let mut st = Stats::default();
let style = Style { snap: false, restore: None, ..Style::default() };
let segs = fit_outline(&circle(25.0, 25.0, 12.0, 700), &style, &mut st).expect("fit");
assert_eq!(st.circles, 0);
for q in sample_cubics(&segs, 0.05) {
assert!((q.distance(Point::new(25.0, 25.0)) - 12.0).abs() < 0.2);
}
}
#[test]
fn the_guard_puts_back_a_plain_fit_when_the_clever_one_strays() {
let mut st = Stats::default();
// An absurdly tight limit makes every smoothed fit "stray".
let style = Style { max_dev: 1e-6, ..Style::default() };
let raw = rounded_square(0.7);
let segs = fit_outline(&raw, &style, &mut st);
assert_eq!(st.reverted, 1);
assert!(segs.is_some_and(|s| !s.is_empty()));
}
#[test]
fn degenerate_outlines_give_none_not_a_panic() {
let mut st = Stats::default();
let s = Style::default();
assert!(fit_outline(&[], &s, &mut st).is_none());
assert!(fit_outline(&[Point::new(1.0, 1.0); 3], &s, &mut st).is_none() || st.reverted > 0);
assert!(fit_outline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0), Point::new(2.0, 0.0)], &s, &mut st).is_none());
let tiny = [Point::new(0.0, 0.0), Point::new(0.01, 0.0), Point::new(0.0, 0.01)];
let _ = fit_outline(&tiny, &s, &mut st);
let _ = fit_outline(&circle(5.0, 5.0, 1e-9, 20), &s, &mut st);
let _ = fit_outline(&circle(5.0, 5.0, 1e7, 50), &s, &mut st);
}
}
+287
View File
@@ -0,0 +1,287 @@
//! Finding a logo's flat colours, and telling flat-colour art from gradients and photos.
use super::unmix::Px;
/// A colour with every channel at or above this is white, which is always a class of its own.
pub(crate) const WHITE_MIN: u8 = 245;
/// `#rrggbb` or `rrggbb`.
pub(crate) fn parse_hex(s: &str) -> Option<[u8; 3]> {
let h = s.trim().trim_start_matches('#');
if h.len() != 6 || !h.is_ascii() {
return None;
}
let v = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok();
Some([v(0)?, v(2)?, v(4)?])
}
fn rgb255(p: &Px) -> [f64; 3] {
let a = f64::from(p[3]).max(1e-6);
[f64::from(p[0]) / a * 255.0, f64::from(p[1]) / a * 255.0, f64::from(p[2]) / a * 255.0]
}
/// Opaque pixels whose 3×3 neighbourhood is uniform (variance below `max_var`): edge pixels, which
/// are blends, do not get a vote.
fn solid_pixels(pix: &[Px], w: usize, h: usize, max_var: f64) -> Vec<[f64; 3]> {
let mut out = Vec::new();
if w < 3 || h < 3 {
return pix.iter().filter(|p| p[3] > 0.98).map(rgb255).collect();
}
for y in 1..h - 1 {
for x in 1..w - 1 {
let Some(c) = pix.get(y * w + x) else { continue };
if c[3] <= 0.98 {
continue;
}
let (mut s, mut s2, mut cnt) = ([0.0f64; 3], [0.0f64; 3], 0.0f64);
let mut opaque = true;
for dy in 0..3 {
for dx in 0..3 {
let Some(q) = pix.get((y + dy - 1) * w + (x + dx - 1)) else { continue };
if q[3] <= 0.98 {
opaque = false;
}
let v = rgb255(q);
for k in 0..3 {
s[k] += v[k];
s2[k] += v[k] * v[k];
}
cnt += 1.0;
}
}
if !opaque || cnt < 9.0 {
continue;
}
let var: f64 = (0..3).map(|k| s2[k] / cnt - (s[k] / cnt).powi(2)).sum();
if var < max_var {
out.push(rgb255(c));
}
}
}
out
}
fn dist2(a: [f64; 3], b: [f64; 3]) -> f64 {
(0..3).map(|k| (a[k] - b[k]).powi(2)).sum()
}
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((self.0 >> 33) as f64) / ((1u64 << 31) as f64)
}
}
fn kmeans(px: &[[f64; 3]], k: usize, seed: u64) -> (Vec<[f64; 3]>, Vec<usize>, f64) {
let mut rng = Lcg(seed);
let mut c: Vec<[f64; 3]> = vec![px[(rng.next() * px.len() as f64) as usize % px.len()]];
while c.len() < k {
let d: Vec<f64> = px.iter().map(|p| c.iter().map(|q| dist2(*p, *q)).fold(f64::MAX, f64::min)).collect();
let total: f64 = d.iter().sum();
if total <= 0.0 {
break;
}
let mut r = rng.next() * total;
let mut pick = px.len() - 1;
for (i, v) in d.iter().enumerate() {
r -= v;
if r <= 0.0 {
pick = i;
break;
}
}
c.push(px[pick]);
}
let mut lab = vec![0usize; px.len()];
for _ in 0..30 {
for (i, p) in px.iter().enumerate() {
lab[i] = (0..c.len()).min_by(|&a, &b| dist2(*p, c[a]).total_cmp(&dist2(*p, c[b]))).unwrap_or(0);
}
let mut sum = vec![[0.0f64; 3]; c.len()];
let mut cnt = vec![0usize; c.len()];
for (p, &l) in px.iter().zip(&lab) {
for j in 0..3 {
sum[l][j] += p[j];
}
cnt[l] += 1;
}
for (i, ci) in c.iter_mut().enumerate() {
if cnt[i] > 0 {
*ci = [sum[i][0] / cnt[i] as f64, sum[i][1] / cnt[i] as f64, sum[i][2] / cnt[i] as f64];
}
}
}
let cost = px.iter().zip(&lab).map(|(p, &l)| dist2(*p, c[l])).sum();
(c, lab, cost)
}
/// Up to `max_colors` flat colours, most common first. White is left out (it is always a class of
/// its own), as are colours that make up under 0.1% of the solid pixels (noise).
pub(crate) fn auto_palette(pix: &[Px], w: usize, h: usize, max_colors: usize) -> Vec<[u8; 3]> {
let mut px = solid_pixels(pix, w, h, 20.0);
if px.len() < max_colors.max(2) * 20 {
px = pix.iter().filter(|p| p[3] > 0.9).map(rgb255).collect();
}
if px.is_empty() {
return Vec::new();
}
if px.len() > 20_000 {
let stride = px.len() / 20_000 + 1;
px = px.into_iter().step_by(stride).collect();
}
let k = max_colors.clamp(1, 12).min(px.len());
let best = (0..4u64).map(|s| kmeans(&px, k, 0x9e37_79b9 + s * 7919)).min_by(|a, b| a.2.total_cmp(&b.2));
let Some((cent, lab, _)) = best else { return Vec::new() };
let mut cnt = vec![0usize; cent.len()];
for &l in &lab {
if let Some(c) = cnt.get_mut(l) {
*c += 1;
}
}
let total = lab.len().max(1);
let mut groups: Vec<([f64; 3], usize)> =
cent.iter().zip(&cnt).filter(|(c, n)| **n * 1000 > total && !c.iter().all(|&v| v >= f64::from(WHITE_MIN))).map(|(c, &n)| (*c, n)).collect();
// Centres closer than 20 are one colour split by noise.
loop {
let mut merged = false;
'outer: for i in 0..groups.len() {
for j in (i + 1)..groups.len() {
if dist2(groups[i].0, groups[j].0) < 400.0 {
let (a, b) = (groups[i], groups[j]);
let n = (a.1 + b.1) as f64;
let c = [0, 1, 2].map(|k| (a.0[k] * a.1 as f64 + b.0[k] * b.1 as f64) / n);
groups[i] = (c, a.1 + b.1);
groups.remove(j);
merged = true;
break 'outer;
}
}
}
if !merged {
break;
}
}
groups.sort_by_key(|g| std::cmp::Reverse(g.1));
groups.iter().map(|(c, _)| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect()
}
/// Colours the current palette cannot explain. `residual` is each pixel's error after the best blend
/// of the known classes (see `unmix`); a solid run of pixels (a pixel and its four neighbours agree)
/// with a large error is a colour that is missing, such as the small accent that was too few
/// pixels to be found at first. Groups of at least three such pixels at least 25 away from every
/// known colour are returned, most common first.
pub(crate) fn unexplained_colours(pix: &[Px], w: usize, h: usize, residual: &[f32], known: &[[u8; 3]]) -> Vec<[u8; 3]> {
let mut pal: Vec<[f64; 3]> = known.iter().map(|c| c.map(f64::from)).collect();
pal.push([255.0, 255.0, 255.0]);
let at = |x: usize, y: usize| pix.get(y * w + x).filter(|p| p[3] > 0.98).map(rgb255);
let mut groups: Vec<([f64; 3], usize)> = Vec::new();
for y in 1..h.saturating_sub(1) {
for x in 1..w.saturating_sub(1) {
if residual.get(y * w + x).copied().unwrap_or(0.0) < 0.0064 {
continue;
}
let Some(c) = at(x, y) else { continue };
let uniform = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)].iter().all(|&(xx, yy)| at(xx, yy).is_some_and(|q| dist2(q, c) < 100.0));
if !uniform || pal.iter().any(|q| dist2(*q, c) < 625.0) {
continue;
}
match groups.iter_mut().find(|(m, _)| dist2(*m, c) < 400.0) {
Some((m, n)) => {
for k in 0..3 {
m[k] = (m[k] * *n as f64 + c[k]) / (*n as f64 + 1.0);
}
*n += 1;
}
None => groups.push((c, 1)),
}
}
}
groups.retain(|g| g.1 >= 3);
groups.sort_by_key(|g| std::cmp::Reverse(g.1));
groups.iter().map(|(c, _)| c.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect()
}
/// Share of solid pixels within a small colour distance of a palette colour (white included).
/// About 1.0 for flat-colour art; far lower for gradients, shading and photos.
pub(crate) fn flat_fraction(pix: &[Px], w: usize, h: usize, palette: &[[u8; 3]]) -> f64 {
let px = solid_pixels(pix, w, h, 60.0);
if px.is_empty() {
return 0.0;
}
let mut pal: Vec<[f64; 3]> = palette.iter().map(|c| c.map(f64::from)).collect();
pal.push([255.0, 255.0, 255.0]);
let near = px.iter().filter(|p| pal.iter().any(|q| dist2(**p, *q) < 22.0 * 22.0)).count();
near as f64 / px.len() as f64
}
#[cfg(test)]
mod tests {
use super::*;
fn blocks(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<Px> {
(0..w * h)
.map(|i| {
let p = f(i % w, i / w);
let a = f32::from(p[3]) / 255.0;
[f32::from(p[0]) / 255.0 * a, f32::from(p[1]) / 255.0 * a, f32::from(p[2]) / 255.0 * a, a]
})
.collect()
}
#[test]
fn hex_parsing() {
assert_eq!(parse_hex("#6C60F6"), Some([0x6c, 0x60, 0xf6]));
assert_eq!(parse_hex("ea4335"), Some([0xea, 0x43, 0x35]));
for bad in ["", "#12345", "#1234567", "#gg0000", "é2345z", " "] {
assert_eq!(parse_hex(bad), None, "{bad:?}");
}
}
#[test]
fn three_flat_colours_are_found_exactly() {
let pix = blocks(60, 20, |x, _| match x / 20 {
0 => [200, 30, 30, 255],
1 => [30, 160, 60, 255],
_ => [60, 70, 180, 255],
});
let mut pal = auto_palette(&pix, 60, 20, 6);
pal.sort();
let mut want = vec![[200, 30, 30], [30, 160, 60], [60, 70, 180]];
want.sort();
assert_eq!(pal, want);
assert!(flat_fraction(&pix, 60, 20, &pal) > 0.99);
}
#[test]
fn white_is_never_in_the_palette_and_transparency_is_ignored() {
let pix = blocks(40, 20, |x, _| {
if x < 10 {
[255, 255, 255, 255]
} else if x < 20 {
[0, 0, 0, 0]
} else {
[10, 120, 200, 255]
}
});
let pal = auto_palette(&pix, 40, 20, 4);
assert_eq!(pal, vec![[10, 120, 200]]);
}
#[test]
fn a_gradient_is_not_flat() {
let pix = blocks(64, 16, |x, _| [(x * 4) as u8, 255 - (x * 4) as u8, 128, 255]);
let pal = auto_palette(&pix, 64, 16, 6);
assert!(flat_fraction(&pix, 64, 16, &pal) < 0.93, "gradient scored flat");
}
#[test]
fn nothing_to_find_in_empty_or_blank_images() {
assert!(auto_palette(&[], 0, 0, 6).is_empty());
let clear = blocks(10, 10, |_, _| [0, 0, 0, 0]);
assert!(auto_palette(&clear, 10, 10, 6).is_empty());
assert_eq!(flat_fraction(&clear, 10, 10, &[]), 0.0);
let white = blocks(10, 10, |_, _| [255, 255, 255, 255]);
assert!(auto_palette(&white, 10, 10, 6).is_empty());
}
}
+484
View File
@@ -0,0 +1,484 @@
//! The logo pipeline end to end: raster in, painted vector shapes out.
use super::field::{blur_plane, upsample};
use super::geom::{Cubic, finite};
use super::march::{area2, contours};
use super::outline::{Stats, Style, fit_outline};
use super::palette::{WHITE_MIN, auto_palette, flat_fraction, parse_hex, unexplained_colours};
use super::restore::RestoreParams;
use super::unmix::{Px, count_ambiguous, unmix};
use crate::{Raster, TraceError, TraceParams, TraceResult, TracedPath};
use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath};
/// Longest side the tracer works at; bigger images are averaged down first and the result scaled up.
const WORK_MAX_SIDE: usize = 1024;
/// Transparent border added around the image so every outline closes.
const PAD: usize = 3;
/// Most cells of the smooth field (about 24 MB per plane).
const FIELD_CELLS_MAX: usize = 6_000_000;
/// Flat-colour score below which the image is refused.
const MIN_FLAT: f64 = 0.93;
/// Most outlines of one colour (above the noise threshold) we will fit; more is refused, not truncated.
const MAX_LOOPS_PER_CLASS: usize = if cfg!(test) { 500 } else { 20_000 };
/// Most pixels `unmix` may have to work on (those that are not exactly one colour). A busier image is
/// averaged down further first, which bounds the time on pathological input.
const EDGE_BUDGET: usize = 150_000;
/// Palette colours closer than this (RGB distance) are one colour.
const SAME_COLOUR: f64 = 12.0;
/// Most colours a palette may hold (white and transparency come on top).
const MAX_COLOURS: usize = 10;
/// Neighbouring colours overlap by this many working pixels so no hairline gap can open.
const OVERLAP: f64 = 0.06;
/// The raster as premultiplied RGBA, averaged down so its longest side is at most [`WORK_MAX_SIDE`].
fn working_copy(img: &Raster, at_least: usize) -> (Vec<Px>, usize, usize, usize) {
let (w, h) = (img.width as usize, img.height as usize);
let factor = w.max(h).div_ceil(WORK_MAX_SIDE).max(at_least).max(1);
let (ow, oh) = (w.div_ceil(factor), h.div_ceil(factor));
let mut out = Vec::with_capacity(ow * oh);
for oy in 0..oh {
for ox in 0..ow {
let mut acc = [0.0f32; 4];
let mut n = 0.0f32;
for y in oy * factor..((oy + 1) * factor).min(h) {
for x in ox * factor..((ox + 1) * factor).min(w) {
let i = (y * w + x) * 4;
if let Some(p) = img.rgba.get(i..i + 4) {
let a = f32::from(p[3]) / 255.0;
acc[0] += f32::from(p[0]) / 255.0 * a;
acc[1] += f32::from(p[1]) / 255.0 * a;
acc[2] += f32::from(p[2]) / 255.0 * a;
acc[3] += a;
n += 1.0;
}
}
}
let n = n.max(1.0);
out.push([acc[0] / n, acc[1] / n, acc[2] / n, acc[3] / n]);
}
}
(out, ow, oh, factor)
}
fn style(params: &TraceParams) -> Style {
// Parameters come from users and agents: a NaN or infinity must not reach the geometry.
let unit = |v: f64, default: f64| if v.is_finite() { (v / 100.0).clamp(0.0, 1.0) } else { default };
let paths = unit(params.paths, 0.6);
let corners = unit(params.corners, 0.5);
Style {
tol: 0.04 + 0.2 * (1.0 - paths),
corner_deg: 90.0 - 55.0 * corners,
snap: params.snap_curves_to_lines,
restore: Some(RestoreParams::default()),
..Style::default()
}
}
/// Transparent regions fully enclosed by artwork (letters knocked out of a badge) become white, so
/// the logo is still right on a dark background. Works on coverage planes in place.
fn fill_knockouts(planes: &mut [Vec<f32>], w: usize, h: usize, white: usize, transparent: usize) {
let n = w * h;
let Some(t) = planes.get(transparent) else { return };
let seed: Vec<bool> = t.iter().take(n).map(|&v| v > 0.2).collect();
// Components of "mostly transparent" pixels; the ones touching the border are the outside.
let mut comp = vec![0u32; n];
let mut next_id = 0u32;
let mut outside: Vec<bool> = vec![false];
let mut stack = Vec::new();
for s in 0..n {
if !seed[s] || comp[s] != 0 {
continue;
}
next_id += 1;
let mut touches = false;
comp[s] = next_id;
stack.push(s);
while let Some(i) = stack.pop() {
let (x, y) = (i % w, i / w);
if x == 0 || y == 0 || x + 1 == w || y + 1 == h {
touches = true;
}
let mut push = |j: usize| {
if seed[j] && comp[j] == 0 {
comp[j] = next_id;
stack.push(j);
}
};
if x > 0 {
push(i - 1);
}
if x + 1 < w {
push(i + 1);
}
if y > 0 {
push(i - w);
}
if y + 1 < h {
push(i + w);
}
}
outside.push(touches);
}
let enclosed: Vec<bool> = comp.iter().map(|&c| c != 0 && !outside.get(c as usize).copied().unwrap_or(true)).collect();
// Grow by one pixel so the blended edge pixels around a knockout come with it.
let mut grown = enclosed.clone();
for y in 0..h {
for x in 0..w {
if enclosed[y * w + x] {
for yy in y.saturating_sub(1)..=(y + 1).min(h - 1) {
for xx in x.saturating_sub(1)..=(x + 1).min(w - 1) {
grown[yy * w + xx] = true;
}
}
}
}
}
for (i, &is_grown) in grown.iter().enumerate() {
if is_grown {
let tv = planes.get(transparent).and_then(|p| p.get(i)).copied().unwrap_or(0.0);
if tv > 0.02 {
if let Some(v) = planes.get_mut(white).and_then(|p| p.get_mut(i)) {
*v += tv;
}
if let Some(v) = planes.get_mut(transparent).and_then(|p| p.get_mut(i)) {
*v = 0.0;
}
}
}
}
}
fn pad_plane(plane: &[f32], w: usize, h: usize, outside: f32) -> Vec<f32> {
let (wp, hp) = (w + 2 * PAD, h + 2 * PAD);
let mut out = vec![outside; wp * hp];
for y in 0..h {
if let (Some(dst), Some(src)) = (out.get_mut((y + PAD) * wp + PAD..(y + PAD) * wp + PAD + w), plane.get(y * w..(y + 1) * w)) {
dst.copy_from_slice(src);
}
}
out
}
/// A loop's `(x, y)` grid points as working-pixel coordinates.
fn to_pixels(loop_pts: &[Point], u: usize) -> Vec<Point> {
loop_pts.iter().map(|p| Point::new((p.x + 0.5) / u as f64 - PAD as f64, (p.y + 0.5) / u as f64 - PAD as f64)).collect()
}
fn point_in_poly(p: Point, poly: &[Point]) -> bool {
let n = poly.len();
let mut inside = false;
for i in 0..n {
let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
inside = !inside;
}
}
inside
}
/// What the working-resolution class map says about the pixels around an outline.
struct Around<'a> {
dominant: &'a [u8],
w: usize,
h: usize,
white: usize,
transparent: usize,
}
/// Grow the vertices of an outer outline that touch another coloured region.
fn grow_into_neighbours(p: &mut [Point], class: usize, map: &Around) {
let n = p.len();
if class == map.white || n < 3 {
return;
}
let src = p.to_vec();
for i in 0..n {
let (Some(&a), Some(&b), Some(&c)) = (src.get((i + n - 1) % n), src.get((i + 1) % n), src.get(i)) else { continue };
let t = b - a;
let len = t.hypot();
if len.is_nan() || len <= 1e-12 {
continue;
}
let t = t / len;
// Interior is on the left of travel, so the outward normal is on the right.
let out = kurbo::Vec2::new(-t.y, t.x);
let q = c + out * 0.45;
if q.x < 0.0 || q.y < 0.0 {
continue;
}
let (ix, iy) = (q.x as usize, q.y as usize);
if ix >= map.w || iy >= map.h {
continue;
}
let d = map.dominant.get(iy * map.w + ix).copied().map_or(map.transparent, usize::from);
if d != class
&& d != map.white
&& d != map.transparent
&& let Some(v) = p.get_mut(i)
{
*v = c + out * OVERLAP;
}
}
}
/// Where working-pixel coordinates land in the source image. Working pixels are `factor` source
/// pixels wide except the last in each direction, which may be narrower: mapping the whole axis
/// with one factor would overshoot the image by up to `factor - 1` pixels.
#[derive(Clone, Copy)]
struct ToSource {
factor: f64,
work: (usize, usize),
source: (usize, usize),
}
impl ToSource {
fn axis(&self, v: f64, work: usize, source: usize) -> f64 {
let last = work.saturating_sub(1) as f64;
if v <= last { v * self.factor } else { last * self.factor + (v - last) * (source as f64 - last * self.factor) }
}
fn point(&self, p: Point) -> Point {
Point::new(self.axis(p.x, self.work.0, self.source.0), self.axis(p.y, self.work.1, self.source.1))
}
}
fn to_subpath(segs: &[Cubic], map: &ToSource) -> Option<SubPath> {
let n = segs.len();
if n == 0 {
return None;
}
let sc = |p: Point| map.point(p);
let mut anchors = Vec::with_capacity(n);
for i in 0..n {
let (cur, prev) = (segs.get(i)?, segs.get((i + n - 1) % n)?);
let (p, h_out, h_in) = (sc(cur[0]), sc(cur[1]), sc(prev[2]));
let (vi, vo) = (p - h_in, h_out - p);
let kind = if vi.hypot() > 1e-9 && vo.hypot() > 1e-9 && vi.cross(vo).abs() < 0.07 * vi.hypot() * vo.hypot() && vi.dot(vo) > 0.0 {
AnchorKind::Smooth
} else {
AnchorKind::Corner
};
anchors.push(Anchor { p, h_in, h_out, kind });
}
anchors.iter().all(|a| finite(a.p) && finite(a.h_in) && finite(a.h_out)).then(|| SubPath::new(anchors, true))
}
struct Shape {
class: usize,
area: f64,
outer: Vec<Cubic>,
holes: Vec<Vec<Cubic>>,
}
/// A hard-edged source (a GIF, say) has no anti-aliasing to read an edge position from, and its
/// outlines would follow the pixel staircase. Blurring the coverage by under a pixel gives the
/// outline a smooth edge to find. Sources with plenty of blended edge pixels are left alone.
fn soften_if_aliased(planes: &mut [Vec<f32>], w: usize, h: usize) -> bool {
let n = w * h;
let dominant: Vec<usize> =
(0..n).map(|i| (0..planes.len()).max_by(|&a, &b| plane_at(planes, a, i).total_cmp(&plane_at(planes, b, i))).unwrap_or(0)).collect();
let (mut boundary, mut blended) = (0usize, 0usize);
for y in 0..h {
for x in 0..w {
let i = y * w + x;
let d = dominant.get(i).copied().unwrap_or(0);
let strongest = (0..planes.len()).map(|c| plane_at(planes, c, i)).fold(0.0f32, f32::max);
if strongest < 0.9 {
blended += 1;
}
let differs = |j: usize| dominant.get(j).is_some_and(|&o| o != d);
if (x + 1 < w && differs(i + 1)) || (y + 1 < h && differs(i + w)) {
boundary += 1;
}
}
}
let aliased = boundary > 0 && blended * 4 < boundary;
if aliased {
for p in planes.iter_mut() {
blur_plane(p, w, h, 0.8);
}
}
aliased
}
fn plane_at(planes: &[Vec<f32>], class: usize, i: usize) -> f32 {
planes.get(class).and_then(|p| p.get(i)).copied().unwrap_or(0.0)
}
/// The unmixing classes for a palette: each colour, then white, then transparent.
fn classes_for(palette: &[[u8; 3]]) -> Vec<[f64; 4]> {
let mut classes: Vec<[f64; 4]> =
palette.iter().map(|c| [f64::from(c[0]) / 255.0, f64::from(c[1]) / 255.0, f64::from(c[2]) / 255.0, 1.0]).collect();
classes.push([1.0, 1.0, 1.0, 1.0]);
classes.push([0.0, 0.0, 0.0, 0.0]);
classes
}
fn distance(a: [u8; 3], b: [u8; 3]) -> f64 {
(0..3).map(|k| (f64::from(a[k]) - f64::from(b[k])).powi(2)).sum::<f64>().sqrt()
}
/// The colours to trace: those the caller named (white is always its own class so it is skipped;
/// colours closer than [`SAME_COLOUR`] count as one; at most [`MAX_COLOURS`]) or the ones found.
fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Result<Vec<[u8; 3]>, TraceError> {
if params.logo_colors.is_empty() {
return Ok(auto_palette(pix, w, h, (params.colors as usize).clamp(2, MAX_COLOURS)));
}
let mut palette: Vec<[u8; 3]> = Vec::new();
for s in &params.logo_colors {
// Every entry is checked, but only the first few distinct colours are kept.
let c = parse_hex(s).ok_or_else(|| TraceError::BadPalette(s.clone()))?;
let white = c.iter().all(|&v| v >= WHITE_MIN);
if palette.len() < MAX_COLOURS && !white && !palette.iter().any(|&q| distance(q, c) < SAME_COLOUR) {
palette.push(c);
}
}
Ok(palette)
}
/// Trace a flat-colour logo.
pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result<TraceResult, TraceError> {
let (w0, h0) = (img.width as usize, img.height as usize);
if w0 == 0 || h0 == 0 || img.rgba.len() != w0 * h0 * 4 {
return Ok(TraceResult::default());
}
let (mut pix, mut w, mut h, mut factor) = working_copy(img, 1);
let mut palette = choose_palette(params, &pix, w, h)?;
if palette.is_empty() {
return Ok(TraceResult::default());
}
let fraction = flat_fraction(&pix, w, h, &palette);
if fraction < MIN_FLAT {
return Err(TraceError::NotFlat { fraction });
}
let mut classes = classes_for(&palette);
// A very busy image is averaged down further until the unmixing work is bounded.
for _ in 0..6 {
if count_ambiguous(&pix, &classes) <= EDGE_BUDGET || w * h <= 16_384 {
break;
}
(pix, w, h, factor) = working_copy(img, factor * 2);
}
let (mut planes, residual) = unmix(&pix, w, h, &classes);
let mut planes = if params.logo_colors.is_empty() && palette.len() < MAX_COLOURS {
// Colours too small to be found at first show up as pixels nothing explains: add them.
let extra = unexplained_colours(&pix, w, h, &residual, &palette);
if extra.is_empty() {
planes
} else {
palette.extend(extra.into_iter().take(MAX_COLOURS - palette.len()));
classes = classes_for(&palette);
unmix(&pix, w, h, &classes).0
}
} else {
std::mem::take(&mut planes)
};
let (white, transparent) = (palette.len(), palette.len() + 1);
let aliased = soften_if_aliased(&mut planes, w, h);
fill_knockouts(&mut planes, w, h, white, transparent);
let n = w * h;
let dominant: Vec<u8> = (0..n)
.map(|i| (0..planes.len()).max_by(|&a, &b| plane_at(&planes, a, i).total_cmp(&plane_at(&planes, b, i))).unwrap_or(transparent) as u8)
.collect();
let around = Around { dominant: &dominant, w, h, white, transparent };
// Smooth fields: where each class is the most present, and by how much.
let (wp, hp) = (w + 2 * PAD, h + 2 * PAD);
let u = (((FIELD_CELLS_MAX / (wp * hp).max(1)) as f64).sqrt() as usize).clamp(2, 8);
let (ow, oh) = (wp * u, hp * u);
let padded: Vec<Vec<f32>> = planes.iter().enumerate().map(|(c, p)| pad_plane(p, w, h, if c == transparent { 1.0 } else { 0.0 })).collect();
let mut best = vec![f32::MIN; ow * oh];
let mut second = vec![f32::MIN; ow * oh];
let mut best_id = vec![0u8; ow * oh];
for (c, pl) in padded.iter().enumerate() {
let m = upsample(pl, wp, hp, u);
if m.len() != ow * oh {
return Ok(TraceResult::default());
}
for (i, &v) in m.iter().enumerate() {
if v > best[i] {
second[i] = best[i];
best[i] = v;
best_id[i] = c as u8;
} else if v > second[i] {
second[i] = v;
}
}
}
let mut style = style(params);
if aliased {
// No anti-aliasing means the edge position is only known to about half a pixel.
style.tol *= 2.5;
style.circle_tol *= 1.8;
style.line_tol *= 1.8;
style.sigma = 0.9;
}
// `noise` is in source pixels (areas under noise/10 are ignored), whatever the working size.
let min_area = ((f64::from(params.noise) / 10.0) / (factor * factor) as f64).max(0.3);
let mut stats = Stats::default();
let mut shapes: Vec<Shape> = Vec::new();
let mut anchors_made = 0usize;
for class in 0..=white {
if class == white && params.ignore_white {
continue;
}
let m = upsample(padded.get(class).map_or(&[][..], Vec::as_slice), wp, hp, u);
let f: Vec<f32> = m.iter().enumerate().map(|(i, &v)| v - if usize::from(best_id[i]) == class { second[i] } else { best[i] }).collect();
drop(m);
let mut outers: Vec<(f64, Vec<Point>)> = Vec::new();
let mut holes: Vec<Vec<Point>> = Vec::new();
for lp in contours(&f, ow, oh) {
let a2 = area2(&lp) / (u * u) as f64;
if a2.abs() / 2.0 < min_area {
continue;
}
// More real outlines than we will fit is refused, not silently truncated.
if outers.len() + holes.len() >= MAX_LOOPS_PER_CLASS {
return Err(TraceError::TooComplex { max: max_anchors });
}
let pts = to_pixels(&lp, u);
if a2 < 0.0 { outers.push((a2.abs() / 2.0, pts)) } else { holes.push(pts) }
}
// Each hole belongs to the smallest outline that contains it, and to no other.
let owner: Vec<Option<usize>> = if params.ignore_white {
holes
.iter()
.map(|hole| {
let p = *hole.first()?;
outers.iter().enumerate().filter(|(_, (_, o))| point_in_poly(p, o)).min_by(|a, b| a.1.0.total_cmp(&b.1.0)).map(|(i, _)| i)
})
.collect()
} else {
Vec::new()
};
for (idx, (area, mut pts)) in outers.into_iter().enumerate() {
grow_into_neighbours(&mut pts, class, &around);
let Some(outer) = fit_outline(&pts, &style, &mut stats) else { continue };
let hole_fits: Vec<Vec<Cubic>> =
holes.iter().zip(&owner).filter(|(_, o)| **o == Some(idx)).filter_map(|(hole, _)| fit_outline(hole, &style, &mut stats)).collect();
anchors_made += outer.len() + hole_fits.iter().map(Vec::len).sum::<usize>();
if anchors_made > max_anchors {
return Err(TraceError::TooComplex { max: max_anchors });
}
shapes.push(Shape { class, area, outer, holes: hole_fits });
}
}
// Painter's order: biggest shapes first, so smaller ones sit on top of what they belong to.
shapes.sort_by(|a, b| b.area.total_cmp(&a.area).then(a.class.cmp(&b.class)));
let to_source = ToSource { factor: factor as f64, work: (w, h), source: (w0, h0) };
let mut out = TraceResult::default();
for s in shapes {
let color = palette.get(s.class).copied().unwrap_or([255, 255, 255]);
let mut subs: Vec<SubPath> = Vec::new();
subs.extend(to_subpath(&s.outer, &to_source));
for hole in &s.holes {
subs.extend(to_subpath(hole, &to_source));
}
if subs.is_empty() {
continue;
}
if !out.palette.contains(&color) {
out.palette.push(color);
}
out.paths.push(TracedPath { path: PathData::new(subs), color, pixels: (s.area * (factor * factor) as f64) as usize, stroke: None });
}
Ok(out)
}
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more